Merge branch 'develop' into feature/import-wizard

* develop:
  actionscript callable updated
  add db ref for onCreate email trigger
  debug email trigger recipient
  add db ref in emailOnTrigger
  action script configuration
  slack message document trigger
  Duration cell mvp
  console log slack message
  slackOnTrigger
  wrap FT_email collectionPath
  refactor send email cloud function
  email on trigger cloud function
This commit is contained in:
Sidney Alcantara
2020-09-02 12:53:31 +10:00
19 changed files with 726 additions and 85 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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);

View File

@@ -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),
};

View File

@@ -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 = "";

View File

@@ -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";

View File

@@ -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),
};

View File

@@ -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),
});
}
});

View File

@@ -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);
}
}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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 (
<MultiSelect

View File

@@ -9,7 +9,7 @@ import _camelCase from "lodash/camelCase";
import AddIcon from "@material-ui/icons/AddCircle";
import IconButton from "@material-ui/core/IconButton";
import InputAdornment from "@material-ui/core/InputAdornment";
const useStyles = makeStyles(Theme =>
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) => {

View File

@@ -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 (
<>
<Typography variant="overline">Allowed roles</Typography>
<Typography variant="body2">
Authenticated user must have at least one of these to run the script
</Typography>
<RoleSelector
label={"Allowed Roles"}
value={config.requiredRoles}
handleChange={handleChange("requiredRoles")}
/>
<Typography variant="overline">Required fields</Typography>
<Typography variant="body2">
All of the selected fields must have a value for the script to run
</Typography>
<ColumnSelector
label={"Required fields"}
value={config.requiredFields}
tableColumns={
columns
? Array.isArray(columns)
? columns
: Object.values(columns)
: []
}
handleChange={handleChange("requiredFields")}
/>
<Divider />
<Typography variant="overline">Confirmation Template</Typography>
<Typography variant="body2">
The action button will not ask for confirmation if this is left
empty
</Typography>
<TextField
label="Confirmation Template"
placeholder="Are sure you want to invest {{stockName}}?"
value={config.confirmation}
onChange={(e) => {
handleChange("confirmation")(e.target.value);
}}
fullWidth
/>
<FormControlLabel
control={
<Switch
@@ -117,46 +160,77 @@ const ConfigFields = ({ fieldType, config, handleChange, tables, columns }) => {
}
label="Set as an action script"
/>
<RoleSelector
label={
"Allowed Roles(Authenticated user must have at least one of these to run the script)"
}
value={config.requiredRoles}
handleChange={handleChange("requiredRoles")}
/>
<ColumnSelector
label={
"Required fields(All of the selected fields must have a value for the script to run)"
}
value={config.requiredFields}
tableColumns={
columns
? Array.isArray(columns)
? columns
: Object.values(columns)
: []
}
handleChange={handleChange("requiredFields")}
/>
{!Boolean(config.isActionScript) ? (
<TextField
label="callable name"
name="callableName"
fullWidth
onChange={e => {
onChange={(e) => {
handleChange("callableName")(e.target.value);
}}
/>
) : (
<>
<Typography variant="overline">action script</Typography>
<Suspense fallback={<FieldSkeleton height={200} />}>
<Suspense fallback={<FieldSkeleton height={180} />}>
<CodeEditor
height={180}
script={config.script}
handleChange={handleChange("script")}
/>
</Suspense>
<FormControlLabel
control={
<Switch
checked={config["redo.enabled"]}
onChange={() =>
handleChange("redo.enabled")(
!Boolean(config["redo.enabled"])
)
}
name="redo toggle"
/>
}
label="enable redo(reruns the same script)"
/>
<FormControlLabel
control={
<Switch
checked={config["undo.enabled"]}
onChange={() =>
handleChange("undo.enabled")(
!Boolean(config["undo.enabled"])
)
}
name="undo toggle"
/>
}
label="enable undo"
/>
{config["undo.enabled"] && (
<>
<Typography variant="overline">
Undo Confirmation Template
</Typography>
<TextField
label="template"
placeholder="are you sure you want to sell your stocks in {{stockName}}"
value={config["undo.confirmation"]}
onChange={(e) => {
handleChange("undo.confirmation")(e.target.value);
}}
fullWidth
/>
<Typography variant="overline">Undo Action script</Typography>
<Suspense fallback={<FieldSkeleton height={180} />}>
<CodeEditor
height={180}
script={config["undo.script"]}
handleChange={handleChange("undo.script")}
/>
</Suspense>
</>
)}
</>
)}
</>
@@ -178,6 +252,7 @@ const ConfigFields = ({ fieldType, config, handleChange, tables, columns }) => {
}
handleChange={handleChange("listenerFields")}
/>
<Typography variant="overline">derivative script</Typography>
<Suspense fallback={<FieldSkeleton height={200} />}>
<CodeEditor
@@ -259,6 +334,7 @@ export default function FormDialog({
>
<DialogContent>
<Grid
style={{ minWidth: 450 }}
container
justify="space-between"
alignContent="flex-start"
@@ -278,7 +354,7 @@ export default function FormDialog({
{
<ConfigForm
type={type}
handleChange={key => update => {
handleChange={(key) => (update) => {
setNewConfig({ ...newConfig, [key]: update });
}}
config={newConfig}

View File

@@ -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) {
<div className={classes.editorWrapper}>
{/* <div id="editor" className={classes.editor} /> */}
<Editor
height="90vh"
height={height}
editorDidMount={handleEditorDidMount}
language="javascript"
value={initialEditorValue}

View File

@@ -18,7 +18,7 @@ import { SnackContext } from "contexts/snackContext";
import { cloudFunction } from "firebase/callables";
import { sanitiseCallableName, isUrl, sanitiseRowData } from "util/fns";
const useStyles = makeStyles(theme =>
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 <UndoIcon />;
case "redo":
return <RefreshIcon />;
default:
return <PlayIcon />;
}
};
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 = (
<Fab
size="small"
@@ -92,34 +108,28 @@ export default function Action({
>
{isRunning ? (
<CircularProgress color="secondary" size={16} thickness={5.6} />
) : hasRan ? (
value.undo ? (
<UndoIcon />
) : (
<RefreshIcon />
)
) : (
<PlayIcon />
getStateIcon(actionState)
)}
</Fab>
);
if ((column as any)?.config?.confirmation)
if (typeof config.confirmation === "string") {
component = (
<Confirmation
message={{
title: (column as any).config.confirmation.title,
body: (column as any).config.confirmation.body.replace(
/\{\{(.*?)\}\}/g,
replacer(row)
),
title: `${column.name} Confirmation`,
body: (actionState === "undo" && config.undoConfirmation
? config.undoConfirmation
: config.confirmation
).replace(/\{\{(.*?)\}\}/g, replacer(row)),
}}
functionName="onClick"
>
{component}
</Confirmation>
);
}
return (
<Grid
container

View File

@@ -0,0 +1,50 @@
import React from "react";
import { CustomCellProps } from "./withCustomCell";
import { makeStyles, createStyles } from "@material-ui/core";
export const timeDistance = (date1, date2) => {
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 <></>;
}

View File

@@ -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);

View File

@@ -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: <DateIcon />, name: "Date", type: FieldType.date },
{ icon: <DateTimeIcon />, name: "Time & Date", type: FieldType.dateTime },
{ icon: <DurationIcon />, name: "Duration", type: FieldType.duration },
{ icon: <UrlIcon />, name: "URL", type: FieldType.url },
{ icon: <RatingIcon />, 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.",