diff --git a/cloud_functions/functions/package.json b/cloud_functions/functions/package.json index 2f84d7f8..f8bf2a97 100644 --- a/cloud_functions/functions/package.json +++ b/cloud_functions/functions/package.json @@ -20,6 +20,7 @@ "@google-cloud/firestore": "^3.7.5", "@google-cloud/storage": "^5.1.2", "@sendgrid/mail": "^7.2.3", + "@slack/web-api": "^5.11.0", "algoliasearch": "^3.35.1", "firebase-admin": "^8.9.2", "firebase-functions": "^3.3.0", diff --git a/cloud_functions/functions/src/actionScript/index.ts b/cloud_functions/functions/src/actionScript/index.ts index 65f8b3d1..b2b904cc 100644 --- a/cloud_functions/functions/src/actionScript/index.ts +++ b/cloud_functions/functions/src/actionScript/index.ts @@ -28,7 +28,7 @@ const missingFieldsReducer = (data: any) => (acc: string[], curr: string) => { } else return acc; }; -const generateSchemaDocPath = tablePath => { +const generateSchemaDocPath = (tablePath) => { const pathComponents = tablePath.split("/"); return `_FIRETABLE_/settings/${ pathComponents[1] === "table" ? "schema" : "groupSchema" @@ -43,7 +43,7 @@ export const actionScript = functions.https.onCall( throw Error(`You are unauthenticated`); } - const { ref, row, column } = data; + const { ref, row, column, action } = data; const schemaDocPath = generateSchemaDocPath(ref.tablePath); const schemaDoc = await db.doc(schemaDocPath).get(); @@ -51,13 +51,16 @@ export const actionScript = functions.https.onCall( if (!schemaDocData) { return { success: false, - message: "no schema found", }; } - const { script, requiredRoles, requiredFields } = schemaDocData.columns[ - column.key - ].config; + const { + script, + requiredRoles, + requiredFields, + undo, + redo, + } = schemaDocData.columns[column.key].config; if (!hasAnyRole(requiredRoles, context)) { throw Error(`You don't have the required roles permissions`); } @@ -89,24 +92,34 @@ export const actionScript = functions.https.onCall( message: string; status: string; success: boolean; - } = await eval(`async({db, auth, utilFns})=>{${script}}`)({ + } = await eval( + `async({db, auth, utilFns})=>{${ + action === "undo" ? undo.script : script + }}` + )({ db, auth, utilFns, }); - return { - success: result.success, - message: result.message, - cellValue: { - redo: true, - status: result.status, - completedAt: serverTimestamp(), - meta: { ranBy: context.auth!.token.email }, - undo: false, - }, - undo: false, - redo: false, - }; + if (result.success) + return { + success: result.success, + message: result.message, + cellValue: { + redo: redo.enabled, + status: result.status, + completedAt: serverTimestamp(), + meta: { ranBy: context.auth!.token.email }, + undo: undo.enabled, + }, + undo: undo.enabled, + redo: redo.enabled, + }; + else + return { + success: false, + message: result.message, + }; } catch (error) { return { success: false, diff --git a/cloud_functions/functions/src/buildTriggers/index.ts b/cloud_functions/functions/src/buildTriggers/index.ts index 30bd2eae..4a808510 100644 --- a/cloud_functions/functions/src/buildTriggers/index.ts +++ b/cloud_functions/functions/src/buildTriggers/index.ts @@ -87,7 +87,7 @@ export const cloudBuildUpdates = functions.pubsub if (query.docs.length !== 0) { const update = { status }; - if (status === "SUCCESS") { + if (status === "SUCCESS" || status === "FAILURE") { update["buildDuration.end"] = serverTimestamp(); } await query.docs[0].ref.update(update); diff --git a/cloud_functions/functions/src/emailOnTrigger/index.ts b/cloud_functions/functions/src/emailOnTrigger/index.ts new file mode 100644 index 00000000..48671774 --- /dev/null +++ b/cloud_functions/functions/src/emailOnTrigger/index.ts @@ -0,0 +1,128 @@ +import { firestore } from "firebase-functions"; + +import { sendEmail } from "../utils/email"; +import { hasRequiredFields } from "../utils"; +import { db } from "../config"; +import _config from "../functionConfig"; // generated using generateConfig.ts +const functionConfig: any = _config; +type EmailOnTriggerConfig = { + collectionPath: string; + templateId: string; + categories: string[]; + onCreate: Boolean; + from: Function; + to: Function; + requiredFields: string[]; + shouldSend: ( + snapshot: + | firestore.DocumentSnapshot + | { + before: firestore.DocumentSnapshot; + after: firestore.DocumentSnapshot; + } + ) => Boolean; + onUpdate: Boolean; +}; +const emailOnCreate = (config: EmailOnTriggerConfig) => + firestore + .document(`${config.collectionPath}/{docId}`) + .onCreate(async (snapshot) => { + try { + const snapshotData = snapshot.data(); + if (!snapshotData) throw Error("no snapshot data"); + + const shouldSend = config.shouldSend(snapshot); + const hasAllRequiredFields = hasRequiredFields( + config.requiredFields, + snapshotData + ); + const to = await config.to(snapshotData, db); + const from = await config.from(snapshotData, db); + console.log(JSON.stringify({ to, from })); + if (shouldSend && hasAllRequiredFields) { + const msg = { + from, + personalizations: [ + { + to, + dynamic_template_data: { + ...snapshotData, + }, + }, + ], + template_id: config.templateId, + categories: config.categories, + attachments: snapshotData.attachments, + }; + const resp = await sendEmail(msg); + console.log(JSON.stringify(resp)); + return true; + } else { + console.log("requirements were not met"); + return false; + } + } catch (error) { + console.warn(JSON.stringify(error.response.body)); + return false; + } + }); + +const emailOnUpdate = (config: EmailOnTriggerConfig) => + firestore + .document(`${config.collectionPath}/{docId}`) + .onUpdate(async (change) => { + try { + const beforeData = change.before.data(); + const afterData = change.after.data(); + if (!beforeData || !afterData) throw Error("no data found in snapshot"); + const shouldSend = config.shouldSend(change); + const hasAllRequiredFields = hasRequiredFields( + config.requiredFields, + afterData + ); + const dynamic_template_data = config.requiredFields.reduce( + (acc: any, curr: string) => { + return { ...acc, [curr]: afterData[curr] }; + }, + {} + ); + if (shouldSend && hasAllRequiredFields) { + const from = await config.from(afterData, db); + const to = await config.to(afterData, db); + console.log(JSON.stringify({ to })); + const msg = { + from, + personalizations: [ + { + to, + dynamic_template_data, + }, + ], + template_id: config.templateId, + categories: config.categories, + }; + const resp = await sendEmail(msg); + console.log(JSON.stringify(resp)); + + return true; + } else { + console.log("requirements were not met"); + return false; + } + } catch (error) { + console.warn(error); + return false; + } + }); + +const emailOnTriggerFns = (config: EmailOnTriggerConfig) => + Object.entries({ + onCreate: config.onCreate ? emailOnCreate(config) : null, + onUpdate: config.onUpdate ? emailOnUpdate(config) : null, + }).reduce((a, [k, v]) => (v === null ? a : { ...a, [k]: v }), {}); + +export const FT_email = { + [`${`${functionConfig.collectionPath}` + .replace(/\//g, "_") + .replace(/_{.*?}_/g, "_")}`]: emailOnTriggerFns(functionConfig), +}; diff --git a/cloud_functions/functions/src/functionConfig.ts b/cloud_functions/functions/src/functionConfig.ts index b1eaf699..d8a39771 100644 --- a/cloud_functions/functions/src/functionConfig.ts +++ b/cloud_functions/functions/src/functionConfig.ts @@ -1,2 +1,70 @@ -export default {} as any; +export default { + collectionPath: "feedback", + onCreate: true, + onUpdate: false, + onDelete: false, + requiredFields: ["email", "message", "type", "user", "source", "pagePath"], + messageDocGenerator: (snapshot) => { + const docData = snapshot.data(); + const { email, message, type, user, source, pagePath } = docData; + const { displayName } = user; + console.log({ displayName, email, message, type, user, source, pagePath }); + + // General/Bug/Idea + const generalText = + `Hey!, ` + + displayName + + "(" + + email + + ")" + + ` has the following general feedback for (` + + source.split(".")[0] + + `):*` + + message + + `* `; + const bugText = + `Bug report: *` + + message + + `*\n by *` + + displayName + + "(" + + email + + ")*\n" + + "reported on:" + + source + + pagePath; + const ideaText = + displayName + + "(" + + email + + ")" + + "has an amazing idea! for " + + source.split(".")[0] + + " \n messaged saying:" + + message; + let text = "blank"; + switch (type) { + case "Bug": + text = bugText; + break; + case "Idea": + text = ideaText; + break; + case "General": + text = generalText; + break; + default: + text = "unknown feedback type"; + } + + if (source.includes("education")) { + return { + text, + emails: ["heath@antler.co", "harini@antler.co", "shams@antler.co"], + }; + } else { + return { text, channel: "C01561T4AMB" }; + } + }, +} as any; export const collectionPath = ""; diff --git a/cloud_functions/functions/src/index.ts b/cloud_functions/functions/src/index.ts index 352adeeb..73978e84 100644 --- a/cloud_functions/functions/src/index.ts +++ b/cloud_functions/functions/src/index.ts @@ -17,3 +17,6 @@ export { FT_subTableStats } from "./subTableStats"; export { FT_compressedThumbnail } from "./compressedThumbnail"; export { actionScript } from "./actionScript"; export { webhook } from "./webhooks"; +export { FT_email } from "./emailOnTrigger"; +export { FT_slack } from "./slackOnTrigger"; +export { slackBotMessageOnCreate } from "./slackOnTrigger/trigger"; diff --git a/cloud_functions/functions/src/slackOnTrigger/index.ts b/cloud_functions/functions/src/slackOnTrigger/index.ts new file mode 100644 index 00000000..e8b4a711 --- /dev/null +++ b/cloud_functions/functions/src/slackOnTrigger/index.ts @@ -0,0 +1,100 @@ +import { firestore } from "firebase-functions"; +import { hasRequiredFields, serverTimestamp } from "../utils"; +import _config from "../functionConfig"; // generated using generateConfig.ts +import { db } from "../config"; +const functionConfig: any = _config; +type SlackOnTriggerConfig = { + collectionPath: string; + onCreate: Boolean; + onUpdate: Boolean; + onDelete: Boolean; + requiredFields: string[]; + messageDocGenerator: ( + snapshot: + | firestore.DocumentSnapshot + | { + before: firestore.DocumentSnapshot; + after: firestore.DocumentSnapshot; + } + // db: FirebaseFirestore.Firestore + ) => Boolean | any; +}; +const slackOnCreate = (config: SlackOnTriggerConfig) => + firestore + .document(`${config.collectionPath}/{docId}`) + .onCreate(async (snapshot) => { + try { + const snapshotData = snapshot.data(); + if (!snapshotData) throw Error("no snapshot data"); + const hasAllRequiredFields = hasRequiredFields( + config.requiredFields, + snapshotData + ); + if (hasAllRequiredFields) { + const messageDoc = await config.messageDocGenerator(snapshot); + if (messageDoc && typeof messageDoc === "object") { + await db + .collection("slackBotMessages") + .add({ createdAt: serverTimestamp(), ...messageDoc }); + return true; + } else { + console.log("message is not sent"); + return false; + } + } else { + console.log("requirements were not met"); + return false; + } + } catch (error) { + console.warn(JSON.stringify(error.response.body)); + return false; + } + }); + +const slackOnUpdate = (config: SlackOnTriggerConfig) => + firestore + .document(`${config.collectionPath}/{docId}`) + .onUpdate(async (change) => { + try { + const beforeData = change.before.data(); + const afterData = change.after.data(); + if (!beforeData || !afterData) throw Error("no data found in snapshot"); + const hasAllRequiredFields = hasRequiredFields( + config.requiredFields, + afterData + ); + if (hasAllRequiredFields) { + const messageDoc = await config.messageDocGenerator(change); + console.log({ messageDoc }); + if (messageDoc && typeof messageDoc === "object") { + console.log("creating slack message doc"); + + await db + .collection("slackBotMessages") + .add({ createdAt: serverTimestamp(), ...messageDoc }); + return true; + } else { + console.log("message is not sent"); + return false; + } + } else { + console.log("requirements were not met"); + return false; + } + } catch (error) { + console.warn(error); + return false; + } + }); + +const slackOnTriggerFns = (config: SlackOnTriggerConfig) => + Object.entries({ + onCreate: config.onCreate ? slackOnCreate(config) : null, + onUpdate: config.onUpdate ? slackOnUpdate(config) : null, + }).reduce((a, [k, v]) => (v === null ? a : { ...a, [k]: v }), {}); + +export const FT_slack = { + [`${`${functionConfig.collectionPath}` + .replace(/\//g, "_") + .replace(/_{.*?}_/g, "_")}`]: slackOnTriggerFns(functionConfig), +}; diff --git a/cloud_functions/functions/src/slackOnTrigger/trigger.ts b/cloud_functions/functions/src/slackOnTrigger/trigger.ts new file mode 100644 index 00000000..2dd6014d --- /dev/null +++ b/cloud_functions/functions/src/slackOnTrigger/trigger.ts @@ -0,0 +1,87 @@ +import { firestore } from "firebase-functions"; + +import { env } from "../config"; +import { WebClient } from "@slack/web-api"; +import { asyncForEach, serverTimestamp } from "../utils"; +// Initialize +const web = new WebClient(env.slackbot.token); + +const messageByChannel = async ({ + text, + channel, +}: { + channel: string; + text: string; +}) => + await web.chat.postMessage({ + text, + channel, + }); +const messageByEmail = async ({ + email, + text, +}: { + email: string; + text: string; +}) => { + try { + const user = await web.users.lookupByEmail({ email }); + if (user.ok) { + const channel = (user as any).user.id as string; + console.log({ channel }); + return await messageByChannel({ + text, + channel, + }); + } else { + return await false; + } + } catch (error) { + console.log(`${error} maybe${email} is not on slack`); + console.log(`${error}`); + return await false; + } +}; + +export const slackBotMessageOnCreate = firestore + .document(`slackBotMessages/{docId}`) + .onCreate(async (snapshot) => { + const docData = snapshot.data(); + if (!docData) { + return snapshot.ref.update({ + delivered: false, + error: "undefined doc", + }); + } + try { + const channels = docData.channel ? [docData.channel] : docData.channels; + const emails = docData.email ? [docData.email] : docData.emails; + if (channels) { + await asyncForEach(channels, async (channel: string) => { + await messageByChannel({ + text: docData.text, + channel, + }); + }); + } else if (emails) { + await asyncForEach(emails, async (email: string) => { + await messageByEmail({ + text: docData.text, + email, + }); + }); + } + + return snapshot.ref.update({ + delivered: true, + updatedAt: serverTimestamp(), + }); + } catch (error) { + console.log(error); + return snapshot.ref.update({ + delivered: false, + updatedAt: serverTimestamp(), + error: JSON.stringify(error), + }); + } + }); diff --git a/cloud_functions/functions/src/utils/index.ts b/cloud_functions/functions/src/utils/index.ts index fada78ec..62ceb48c 100644 --- a/cloud_functions/functions/src/utils/index.ts +++ b/cloud_functions/functions/src/utils/index.ts @@ -9,3 +9,14 @@ export const replacer = (data: any) => (m: string, key: string) => { const defaultValue = key.split(":")[1] || ""; return _.get(data, objKey, defaultValue); }; + +export const hasRequiredFields = (requiredFields: string[], data: any) => + requiredFields.reduce((acc: boolean, currField: string) => { + if (data[currField] === undefined || data[currField] === null) return false; + else return acc; + }, true); +export async function asyncForEach(array: any[], callback: Function) { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index, array); + } +} diff --git a/cloud_functions/functions/yarn.lock b/cloud_functions/functions/yarn.lock index cd95009b..0c892ae0 100644 --- a/cloud_functions/functions/yarn.lock +++ b/cloud_functions/functions/yarn.lock @@ -378,6 +378,35 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== +"@slack/logger@>=1.0.0 <3.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@slack/logger/-/logger-2.0.0.tgz#6a4e1c755849bc0f66dac08a8be54ce790ec0e6b" + integrity sha512-OkIJpiU2fz6HOJujhlhfIGrc8hB4ibqtf7nnbJQDerG0BqwZCfmgtK5sWzZ0TkXVRBKD5MpLrTmCYyMxoMCgPw== + dependencies: + "@types/node" ">=8.9.0" + +"@slack/types@^1.7.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@slack/types/-/types-1.8.0.tgz#a5a0b31bace03f524174991dfc41c60311e6f32f" + integrity sha512-YvLCtxqbIdCCI+xMQBFH3GJVhRp8jJNl8BUE0RgJlZcDF+wXSB1wkcgLz7zHtD3oOF39GedYiE1e/rQrZ4Dr1A== + +"@slack/web-api@^5.11.0": + version "5.11.0" + resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-5.11.0.tgz#6549ec71d13c2837cc672cbf7793a88eef22802f" + integrity sha512-4a/uj7IZjFLu7Qmq0nH74ecLqk1iI/9x3yRS/v6M5vXDyc5lEruRFp4d5/bz4eN5Bathlq4Bws0wioY516fPag== + dependencies: + "@slack/logger" ">=1.0.0 <3.0.0" + "@slack/types" "^1.7.0" + "@types/is-stream" "^1.1.0" + "@types/node" ">=8.9.0" + "@types/p-queue" "^2.3.2" + axios "^0.19.0" + eventemitter3 "^3.1.0" + form-data "^2.5.0" + is-stream "^1.1.0" + p-queue "^2.4.2" + p-retry "^4.0.0" + "@szmarczak/http-timer@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" @@ -477,6 +506,13 @@ dependencies: "@types/node" "*" +"@types/is-stream@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1" + integrity sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg== + dependencies: + "@types/node" "*" + "@types/json2csv@^5.0.1": version "5.0.1" resolved "https://registry.yarnpkg.com/@types/json2csv/-/json2csv-5.0.1.tgz#576d38515dfedeabf46eb85e790894b8df72ab40" @@ -509,6 +545,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.4.tgz#1581d6c16e3d4803eb079c87d4ac893ee7501c2c" integrity sha512-x26ur3dSXgv5AwKS0lNfbjpCakGIduWU1DU91Zz58ONRWrIKGunmZBNv4P7N+e27sJkiGDsw/3fT4AtsqQBrBA== +"@types/node@>=8.9.0": + version "14.6.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.2.tgz#264b44c5a28dfa80198fc2f7b6d3c8a054b9491f" + integrity sha512-onlIwbaeqvZyniGPfdw/TEhKIh79pz66L1q06WUQqJLnAb6wbjvOtepLYTGHTqzdXgBYIE3ZdmqHDGsRsbBz7A== + "@types/node@^8.10.59": version "8.10.60" resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.60.tgz#73eb4d1e1c8aa5dc724363b57db019cf28863ef7" @@ -519,6 +560,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== +"@types/p-queue@^2.3.2": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@types/p-queue/-/p-queue-2.3.2.tgz#16bc5fece69ef85efaf2bce8b13f3ebe39c5a1c8" + integrity sha512-eKAv5Ql6k78dh3ULCsSBxX6bFNuGjTmof5Q/T6PiECDq0Yf8IIn46jCyp3RJvCi8owaEmm3DZH1PEImjBMd/vQ== + "@types/qs@*": version "6.9.1" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.1.tgz#937fab3194766256ee09fcd40b781740758617e7" @@ -529,6 +575,11 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== +"@types/retry@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/serve-static@*": version "1.13.3" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1" @@ -853,7 +904,7 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug== -axios@^0.19.2: +axios@^0.19.0, axios@^0.19.2: version "0.19.2" resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== @@ -2205,6 +2256,11 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +eventemitter3@^3.1.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" + integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== + events@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -2650,6 +2706,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -4910,11 +4975,24 @@ p-pipe@^3.0.0: resolved "https://registry.yarnpkg.com/p-pipe/-/p-pipe-3.1.0.tgz#48b57c922aa2e1af6a6404cb7c6bf0eb9cc8e60e" integrity sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw== +p-queue@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-2.4.2.tgz#03609826682b743be9a22dba25051bd46724fc34" + integrity sha512-n8/y+yDJwBjoLQe1GSJbbaYQLTI7QHNZI2+rpmCDbe++WLf9HC3gf6iqj5yfPAV71W4UF3ql5W1+UBPXoXTxng== + p-reduce@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-1.0.0.tgz#18c2b0dd936a4690a529f8231f58a0fdb6a47dfa" integrity sha1-GMKw3ZNqRpClKfgjH1ig/bakffo= +p-retry@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.2.0.tgz#ea9066c6b44f23cab4cd42f6147cdbbc6604da5d" + integrity sha512-jPH38/MRh263KKcq0wBNOGFJbm+U6784RilTmHjB/HM9kH9V8WlCpVUcdOmip9cjXOh6MxZ5yk1z2SjDUJfWmA== + dependencies: + "@types/retry" "^0.12.0" + retry "^0.12.0" + p-timeout@^1.1.1: version "1.2.1" resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-1.2.1.tgz#5eb3b353b7fce99f101a1038880bb054ebbea386" @@ -5537,6 +5615,11 @@ retry-request@^4.1.1: debug "^4.1.1" through2 "^3.0.1" +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" diff --git a/www/src/components/Table/ColumnHeader.tsx b/www/src/components/Table/ColumnHeader.tsx index 61f38e9c..80b80db3 100644 --- a/www/src/components/Table/ColumnHeader.tsx +++ b/www/src/components/Table/ColumnHeader.tsx @@ -18,12 +18,11 @@ import { getFieldIcon } from "constants/fields"; import { useFiretableContext } from "contexts/firetableContext"; import { FiretableOrderBy } from "hooks/useFiretable"; -const useStyles = makeStyles(theme => +const useStyles = makeStyles((theme) => createStyles({ root: { height: "100%", "& svg, & button": { display: "block" }, - color: theme.palette.text.secondary, transition: theme.transitions.create("color", { duration: theme.transitions.duration.short, diff --git a/www/src/components/Table/ColumnMenu/ConfigFields/ColumnSelector.tsx b/www/src/components/Table/ColumnMenu/Settings/ConfigFields/ColumnSelector.tsx similarity index 81% rename from www/src/components/Table/ColumnMenu/ConfigFields/ColumnSelector.tsx rename to www/src/components/Table/ColumnMenu/Settings/ConfigFields/ColumnSelector.tsx index f83f3821..f59e8e42 100644 --- a/www/src/components/Table/ColumnMenu/ConfigFields/ColumnSelector.tsx +++ b/www/src/components/Table/ColumnMenu/Settings/ConfigFields/ColumnSelector.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { FieldType } from "constants/fields"; import MultiSelect from "@antlerengineering/multiselect"; -import { db } from "../../../../firebase"; +import { db } from "../../../../../firebase"; const ColumnSelector = ({ tableColumns, handleChange, @@ -18,7 +18,7 @@ const ColumnSelector = ({ label?: string; }) => { const [columns, setColumns] = useState(tableColumns ?? []); - const getColumns = async table => { + const getColumns = async (table) => { const tableConfigDoc = await db .doc(`_FIRETABLE_/settings/schema/${table}`) .get(); @@ -33,8 +33,8 @@ const ColumnSelector = ({ }, [table]); const options = columns ? Object.values(columns) - .filter(col => (validTypes ? validTypes.includes(col.type) : true)) - .map(col => ({ value: col.key, label: col.name })) + .filter((col) => (validTypes ? validTypes.includes(col.type) : true)) + .map((col) => ({ value: col.key, label: col.name })) : []; return ( +const useStyles = makeStyles((Theme) => createStyles({ root: {}, field: { @@ -48,7 +48,7 @@ export default function OptionsInput(props: any) { value={newOption} className={classes.field} label={props.placeholder ?? "New Option"} - onChange={e => { + onChange={(e) => { setNewOption(e.target.value); }} onKeyPress={(e: any) => { diff --git a/www/src/components/Table/ColumnMenu/Settings.tsx b/www/src/components/Table/ColumnMenu/Settings/index.tsx similarity index 70% rename from www/src/components/Table/ColumnMenu/Settings.tsx rename to www/src/components/Table/ColumnMenu/Settings/index.tsx index 77f94b42..76b64c97 100644 --- a/www/src/components/Table/ColumnMenu/Settings.tsx +++ b/www/src/components/Table/ColumnMenu/Settings/index.tsx @@ -10,6 +10,7 @@ import { TextField, Switch, FormControlLabel, + Divider, } from "@material-ui/core"; import CloseIcon from "@material-ui/icons/Close"; import { FieldType } from "constants/fields"; @@ -17,12 +18,12 @@ import OptionsInput from "./ConfigFields/OptionsInput"; import { useFiretableContext } from "contexts/firetableContext"; import MultiSelect from "@antlerengineering/multiselect"; import _sortBy from "lodash/sortBy"; -import FieldsDropdown from "./FieldsDropdown"; +import FieldsDropdown from "../FieldsDropdown"; import ColumnSelector from "./ConfigFields/ColumnSelector"; import FieldSkeleton from "components/SideDrawer/Form/FieldSkeleton"; import RoleSelector from "components/RolesSelector"; -const CodeEditor = lazy(() => - import("../editors/CodeEditor" /* webpackChunkName: "CodeEditor" */) +const CodeEditor = lazy( + () => import("../../editors/CodeEditor" /* webpackChunkName: "CodeEditor" */) ); const ConfigFields = ({ fieldType, config, handleChange, tables, columns }) => { switch (fieldType) { @@ -50,7 +51,7 @@ const ConfigFields = ({ fieldType, config, handleChange, tables, columns }) => { ); case FieldType.connectTable: const tableOptions = _sortBy( - tables?.map(t => ({ + tables?.map((t) => ({ label: `${t.section} - ${t.name}`, value: t.collection, })) ?? [], @@ -77,7 +78,7 @@ const ConfigFields = ({ fieldType, config, handleChange, tables, columns }) => { label="filter template" name="filters" fullWidth - onChange={e => { + onChange={(e) => { handleChange("filters")(e.target.value); }} /> @@ -103,6 +104,48 @@ const ConfigFields = ({ fieldType, config, handleChange, tables, columns }) => { case FieldType.action: return ( <> + Allowed roles + + Authenticated user must have at least one of these to run the script + + + + Required fields + + All of the selected fields must have a value for the script to run + + + + Confirmation Template + + The action button will not ask for confirmation if this is left + empty + + + { + handleChange("confirmation")(e.target.value); + }} + fullWidth + /> { } label="Set as an action script" /> - - - {!Boolean(config.isActionScript) ? ( { + onChange={(e) => { handleChange("callableName")(e.target.value); }} /> ) : ( <> action script - }> + }> + + handleChange("redo.enabled")( + !Boolean(config["redo.enabled"]) + ) + } + name="redo toggle" + /> + } + label="enable redo(reruns the same script)" + /> + + handleChange("undo.enabled")( + !Boolean(config["undo.enabled"]) + ) + } + name="undo toggle" + /> + } + label="enable undo" + /> + {config["undo.enabled"] && ( + <> + + Undo Confirmation Template + + { + handleChange("undo.confirmation")(e.target.value); + }} + fullWidth + /> + Undo Action script + }> + + + + )} )} @@ -178,6 +252,7 @@ const ConfigFields = ({ fieldType, config, handleChange, tables, columns }) => { } handleChange={handleChange("listenerFields")} /> + derivative script }> update => { + handleChange={(key) => (update) => { setNewConfig({ ...newConfig, [key]: update }); }} config={newConfig} diff --git a/www/src/components/Table/editors/CodeEditor.tsx b/www/src/components/Table/editors/CodeEditor.tsx index da9c9a35..aa43a228 100644 --- a/www/src/components/Table/editors/CodeEditor.tsx +++ b/www/src/components/Table/editors/CodeEditor.tsx @@ -5,7 +5,7 @@ import { useFiretableContext } from "contexts/firetableContext"; import { FieldType } from "constants/fields"; import { setTimeout } from "timers"; -const useStyles = makeStyles(theme => +const useStyles = makeStyles((theme) => createStyles({ editorWrapper: { position: "relative", minWidth: 800 }, @@ -31,7 +31,7 @@ const useStyles = makeStyles(theme => ); export default function CodeEditor(props: any) { - const { handleChange, script } = props; + const { handleChange, script, height = "90hv" } = props; const [initialEditorValue] = useState(script ?? ""); const { tableState } = useFiretableContext(); @@ -45,7 +45,7 @@ export default function CodeEditor(props: any) { function listenEditorChanges() { setTimeout(() => { - editorRef.current?.onDidChangeModelContent(ev => { + editorRef.current?.onDidChangeModelContent((ev) => { handleChange(editorRef.current.getValue()); }); }, 2000); @@ -63,7 +63,7 @@ export default function CodeEditor(props: any) { // console.timeLog(firebaseAuthDefs); monaco .init() - .then(monacoInstance => { + .then((monacoInstance) => { monacoInstance.languages.typescript.javascriptDefaults.addExtraLib( firestoreDefs ); @@ -122,7 +122,7 @@ export default function CodeEditor(props: any) { return `static ${columnKey}:string`; case FieldType.singleSelect: const typeString = [ - ...column.config.options.map(opt => `"${opt}"`), + ...column.config.options.map((opt) => `"${opt}"`), // "string", ].join(" | "); return `static ${columnKey}:${typeString}`; @@ -140,7 +140,7 @@ export default function CodeEditor(props: any) { ); // monacoInstance.editor.create(wrapper, properties); }) - .catch(error => + .catch((error) => console.error( "An error occurred during initialization of Monaco: ", error @@ -154,7 +154,7 @@ export default function CodeEditor(props: any) {
{/*
*/} +const useStyles = makeStyles((theme) => createStyles({ root: { padding: theme.spacing(0, 0.375, 0, 1.5) }, labelContainer: { overflowX: "hidden" }, @@ -32,6 +32,16 @@ const replacer = (data: any) => (m: string, key: string) => { return _get(data, objKey, defaultValue); }; +const getStateIcon = (actionState) => { + switch (actionState) { + case "undo": + return ; + case "redo": + return ; + default: + return ; + } +}; export default function Action({ column, row, @@ -66,13 +76,13 @@ export default function Action({ cloudFunction( callableName, data, - response => { + (response) => { const { message, cellValue, success } = response.data; setIsRunning(false); snack.open({ message, severity: success ? "success" : "error" }); if (cellValue) onSubmit(cellValue); }, - error => { + (error) => { console.error("ERROR", callableName, error); setIsRunning(false); snack.open({ message: JSON.stringify(error), severity: "error" }); @@ -80,6 +90,12 @@ export default function Action({ ); }; const hasRan = value && value.status; + + const actionState: "run" | "undo" | "redo" = hasRan + ? value.undo + ? "undo" + : "redo" + : "run"; let component = ( {isRunning ? ( - ) : hasRan ? ( - value.undo ? ( - - ) : ( - - ) ) : ( - + getStateIcon(actionState) )} ); - if ((column as any)?.config?.confirmation) + if (typeof config.confirmation === "string") { component = ( {component} ); - + } return ( { + let distance = Math.abs(date1 - date2); + const hours = Math.floor(distance / 3600000); + distance -= hours * 3600000; + const minutes = Math.floor(distance / 60000); + distance -= minutes * 60000; + const seconds = Math.floor(distance / 1000); + return `${hours ? `${hours}:` : ""}${("0" + minutes).slice(-2)}:${( + "0" + seconds + ).slice(-2)}`; +}; + +const useStyles = makeStyles((theme) => + createStyles({ + root: { + height: "100%", + }, + }) +); + +export default function Duration({ + rowIdx, + column, + value, + onSubmit, +}: CustomCellProps) { + const classes = useStyles(); + const startDate = value.start?.toDate(); + const endDate = value.end?.toDate(); + if (!startDate && !endDate) { + return <>; + } + if (startDate && !endDate) { + const now = new Date(); + const duration = timeDistance(startDate, now); + + return <>{duration}; + } + if (startDate && endDate) { + const duration = timeDistance(endDate, startDate); + + return <>{duration}; + } + return <>; +} diff --git a/www/src/components/Table/formatters/index.tsx b/www/src/components/Table/formatters/index.tsx index 5530565b..72d3d8ed 100644 --- a/www/src/components/Table/formatters/index.tsx +++ b/www/src/components/Table/formatters/index.tsx @@ -7,6 +7,9 @@ const MultiSelect = lazy( () => import("./MultiSelect" /* webpackChunkName: "MultiSelect" */) ); const DatePicker = lazy(() => import("./Date" /* webpackChunkName: "Date" */)); +const Duration = lazy( + () => import("./Duration" /* webpackChunkName: "Duration" */) +); const Rating = lazy(() => import("./Rating" /* webpackChunkName: "Rating" */)); const Checkbox = lazy( () => import("./Checkbox" /* webpackChunkName: "Checkbox" */) @@ -55,6 +58,8 @@ export const getFormatter = (column: any, readOnly: boolean = false) => { case FieldType.date: case FieldType.dateTime: return withCustomCell(DatePicker, readOnly); + case FieldType.duration: + return withCustomCell(Duration, readOnly); case FieldType.rating: return withCustomCell(Rating, readOnly); @@ -91,8 +96,10 @@ export const getFormatter = (column: any, readOnly: boolean = false) => { case FieldType.user: return withCustomCell(User, readOnly); + case FieldType.code: return withCustomCell(Code, readOnly); + case FieldType.richText: return withCustomCell(RichText, readOnly); diff --git a/www/src/constants/fields.tsx b/www/src/constants/fields.tsx index 57b87d5f..1f08b38c 100644 --- a/www/src/constants/fields.tsx +++ b/www/src/constants/fields.tsx @@ -13,6 +13,7 @@ import PercentageIcon from "assets/icons/Percentage"; import DateIcon from "@material-ui/icons/Today"; import DateTimeIcon from "@material-ui/icons/AccessTime"; +import DurationIcon from "@material-ui/icons/Timer"; import UrlIcon from "@material-ui/icons/Link"; @@ -47,6 +48,7 @@ export { PercentageIcon, DateIcon, DateTimeIcon, + DurationIcon, UrlIcon, RatingIcon, ImageIcon, @@ -77,6 +79,7 @@ export enum FieldType { date = "DATE", dateTime = "DATE_TIME", + duration = "DURATION", url = "URL", rating = "RATING", @@ -115,6 +118,7 @@ export const FIELDS = [ { icon: , name: "Date", type: FieldType.date }, { icon: , name: "Time & Date", type: FieldType.dateTime }, + { icon: , name: "Duration", type: FieldType.duration }, { icon: , name: "URL", type: FieldType.url }, { icon: , name: "Rating", type: FieldType.rating }, @@ -171,6 +175,7 @@ export const FIELD_TYPE_DESCRIPTIONS = { "Date displayed and input as YYYY/MM/DD or input using a picker module.", [FieldType.dateTime]: "Time and Date can be written as YYYY/MM/DD hh:mm (am/pm) or input using a picker module.", + [FieldType.duration]: "Duration calculated from two timestamps.", [FieldType.url]: "Web address. Firetable does not validate URLs.", [FieldType.rating]: "Rating displayed as stars from 0 to 4.",