mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
Merge branch 'feature/spark-ui' into v2
* feature/spark-ui: (23 commits) spark to extension migration modal implementation Minor update rename spark to extension in extensionsLib rename shouldRun to conditions, label to name extension migration guide modal rename spark to extension code-wise spark modal rename spark to extension in ui beautify spark editor default body indentation spark ui connect to backend twilio spark typing updated spark templates spark code editor context type fix spark compiler and deploy ui spark editor validation spark editor help information spark condition and body type hints spark default body on create spark editor ui update spark editor list logic implementation ...
This commit is contained in:
1264
cloud_functions/functions/.yarn/releases/yarn-1.22.5.cjs
vendored
1264
cloud_functions/functions/.yarn/releases/yarn-1.22.5.cjs
vendored
File diff suppressed because it is too large
Load Diff
@@ -46,13 +46,13 @@ const main = async (functionType: string, configString: string) => {
|
||||
}"`;
|
||||
break;
|
||||
|
||||
case "FT_spark":
|
||||
const sparkSchemaDoc = await db
|
||||
case "FT_extension":
|
||||
const extensionSchemaDoc = await db
|
||||
.doc(`_FIRETABLE_/settings/schema/${configString}`)
|
||||
.get();
|
||||
const sparkSchemaData = sparkSchemaDoc.data();
|
||||
const extensionSchemaData = extensionSchemaDoc.data();
|
||||
|
||||
configData = `export default [${sparkSchemaData?.sparks.join(
|
||||
configData = `export default [${extensionSchemaData?.extensions.join(
|
||||
",\n"
|
||||
)}]\n export const collectionPath='${configString}';`;
|
||||
case "FT_aggregates":
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
|
||||
// export { FT_compressedThumbnail } from "./compressedThumbnail";
|
||||
|
||||
export {getAlgoliaSearchKey} from './algoliaSearchKey'
|
||||
export { getAlgoliaSearchKey } from "./algoliaSearchKey";
|
||||
|
||||
//deprecated, updated implementation moved to FT_build folder and used within sparks table functions
|
||||
//deprecated, updated implementation moved to FT_build folder and used within extensions table functions
|
||||
// export { FT_derivatives } from "./derivatives";
|
||||
// export { FT_algolia } from "./algolia";
|
||||
// export { FT_email } from "./emailOnTrigger";
|
||||
// export { FT_slack } from "./slackOnTrigger";
|
||||
// export { FT_sync } from "./collectionSync";
|
||||
// export { FT_spark } from "./sparks";
|
||||
// export { FT_extension } from "./extensions";
|
||||
// export { FT_history } from "./history";
|
||||
// export { slackBotMessageOnCreate } from "./slackOnTrigger/trigger";
|
||||
// export { slackBotMessageOnCreate } from "./slackOnTrigger/trigger";
|
||||
|
||||
80
ft_build/compiler/index.ts
Normal file
80
ft_build/compiler/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { addPackages, addExtensionLib, asyncExecute } from "./terminal";
|
||||
const fs = require("fs");
|
||||
import { generateConfigFromTableSchema } from "./loader";
|
||||
import { commandErrorHandler } from "../utils";
|
||||
const path = require("path");
|
||||
import admin from "firebase-admin";
|
||||
|
||||
export default async function generateConfig(
|
||||
schemaPath: string,
|
||||
user: admin.auth.UserRecord,
|
||||
streamLogger
|
||||
) {
|
||||
return await generateConfigFromTableSchema(
|
||||
schemaPath,
|
||||
user,
|
||||
streamLogger
|
||||
).then(async (success) => {
|
||||
if (!success) {
|
||||
await streamLogger.info(
|
||||
`generateConfigFromTableSchema failed to complete`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await streamLogger.info(`generateConfigFromTableSchema done`);
|
||||
const configFile = fs.readFileSync(
|
||||
path.resolve(__dirname, "../functions/src/functionConfig.ts"),
|
||||
"utf-8"
|
||||
);
|
||||
await streamLogger.info(`configFile: ${JSON.stringify(configFile)}`);
|
||||
const requiredDependencies = configFile.match(
|
||||
/(?<=(require\(("|'))).*?(?=("|')\))/g
|
||||
);
|
||||
if (requiredDependencies) {
|
||||
const packgesAdded = await addPackages(
|
||||
requiredDependencies.map((p: any) => ({ name: p })),
|
||||
user,
|
||||
streamLogger
|
||||
);
|
||||
if (!packgesAdded) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await streamLogger.info(
|
||||
`requiredDependencies: ${JSON.stringify(requiredDependencies)}`
|
||||
);
|
||||
|
||||
const isFunctionConfigValid = await asyncExecute(
|
||||
"cd build/functions/src; tsc functionConfig.ts",
|
||||
commandErrorHandler(
|
||||
{
|
||||
user,
|
||||
functionConfigTs: configFile,
|
||||
description: `Invalid compiled functionConfig.ts`,
|
||||
},
|
||||
streamLogger
|
||||
)
|
||||
);
|
||||
await streamLogger.info(
|
||||
`isFunctionConfigValid: ${JSON.stringify(isFunctionConfigValid)}`
|
||||
);
|
||||
if (!isFunctionConfigValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { extensionsConfig } = require("../functions/src/functionConfig.js");
|
||||
const requiredExtensions = extensionsConfig.map((s: any) => s.type);
|
||||
await streamLogger.info(
|
||||
`requiredExtensions: ${JSON.stringify(requiredExtensions)}`
|
||||
);
|
||||
|
||||
for (const lib of requiredExtensions) {
|
||||
const success = await addExtensionLib(lib, user, streamLogger);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
209
ft_build/compiler/loader.ts
Normal file
209
ft_build/compiler/loader.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { db } from "../firebaseConfig";
|
||||
const fs = require("fs");
|
||||
const beautify = require("js-beautify").js;
|
||||
import admin from "firebase-admin";
|
||||
import { parseExtensionsConfig } from "../utils";
|
||||
|
||||
export const generateConfigFromTableSchema = async (
|
||||
schemaDocPath: string,
|
||||
user: admin.auth.UserRecord,
|
||||
streamLogger
|
||||
) => {
|
||||
await streamLogger.info("getting schema...");
|
||||
const schemaDoc = await db.doc(schemaDocPath).get();
|
||||
const schemaData = schemaDoc.data();
|
||||
try {
|
||||
if (!schemaData) throw new Error("no schema found");
|
||||
|
||||
// Temporarily disabled because this is super long
|
||||
// await streamLogger.info(`schemaData: ${JSON.stringify(schemaData)}`);
|
||||
const derivativeColumns = Object.values(schemaData.columns).filter(
|
||||
(col: any) => col.type === "DERIVATIVE"
|
||||
);
|
||||
await streamLogger.info(
|
||||
`derivativeColumns: ${JSON.stringify(derivativeColumns)}`
|
||||
);
|
||||
const derivativesConfig = `[${derivativeColumns.reduce(
|
||||
(acc, currColumn: any) => {
|
||||
if (
|
||||
!currColumn.config.listenerFields ||
|
||||
currColumn.config.listenerFields.length === 0
|
||||
)
|
||||
throw new Error(
|
||||
`${currColumn.key} derivative is missing listener fields`
|
||||
);
|
||||
if (currColumn.config.listenerFields.includes(currColumn.key))
|
||||
throw new Error(
|
||||
`${currColumn.key} derivative has its own key as a listener field`
|
||||
);
|
||||
return `${acc}{\nfieldName:'${
|
||||
currColumn.key
|
||||
}',evaluate:async ({row,ref,db,auth,storage,utilFns}) =>{${
|
||||
currColumn.config.script
|
||||
}},\nlistenerFields:[${currColumn.config.listenerFields
|
||||
.map((fieldKey: string) => `"${fieldKey}"`)
|
||||
.join(",\n")}]},\n`;
|
||||
},
|
||||
""
|
||||
)}]`;
|
||||
await streamLogger.info(
|
||||
`derivativesConfig: ${JSON.stringify(derivativesConfig)}`
|
||||
);
|
||||
|
||||
const initializableColumns = Object.values(
|
||||
schemaData.columns
|
||||
).filter((col: any) => Boolean(col.config?.defaultValue));
|
||||
await streamLogger.info(
|
||||
`initializableColumns: ${JSON.stringify(initializableColumns)}`
|
||||
);
|
||||
const initializeConfig = `[${initializableColumns.reduce(
|
||||
(acc, currColumn: any) => {
|
||||
if (currColumn.config.defaultValue.type === "static") {
|
||||
return `${acc}{\nfieldName:'${currColumn.key}',
|
||||
type:"${currColumn.config.defaultValue.type}",
|
||||
value:${
|
||||
typeof currColumn.config.defaultValue.value === "string"
|
||||
? `"${currColumn.config.defaultValue.value}"`
|
||||
: JSON.stringify(currColumn.config.defaultValue.value)
|
||||
},
|
||||
},\n`;
|
||||
} else if (currColumn.config.defaultValue.type === "dynamic") {
|
||||
return `${acc}{\nfieldName:'${currColumn.key}',
|
||||
type:"${currColumn.config.defaultValue.type}",
|
||||
script:async ({row,ref,db,auth,utilFns}) =>{${currColumn.config.defaultValue.script}},
|
||||
},\n`;
|
||||
} else {
|
||||
return `${acc}{\nfieldName:'${currColumn.key}',
|
||||
type:"${currColumn.config.defaultValue.type}"
|
||||
},\n`;
|
||||
}
|
||||
},
|
||||
""
|
||||
)}]`;
|
||||
await streamLogger.info(
|
||||
`initializeConfig: ${JSON.stringify(initializeConfig)}`
|
||||
);
|
||||
const documentSelectColumns = Object.values(schemaData.columns).filter(
|
||||
(col: any) => col.type === "DOCUMENT_SELECT" && col.config?.trackedFields
|
||||
);
|
||||
const documentSelectConfig = `[${documentSelectColumns.reduce(
|
||||
(acc, currColumn: any) => {
|
||||
return `${acc}{\nfieldName:'${
|
||||
currColumn.key
|
||||
}',\ntrackedFields:[${currColumn.config.trackedFields
|
||||
.map((fieldKey: string) => `"${fieldKey}"`)
|
||||
.join(",\n")}]},\n`;
|
||||
},
|
||||
""
|
||||
)}]`;
|
||||
await streamLogger.info(
|
||||
`documentSelectColumns: ${JSON.stringify(documentSelectColumns)}`
|
||||
);
|
||||
|
||||
const extensionsConfig = parseExtensionsConfig(
|
||||
schemaData.extensions,
|
||||
user,
|
||||
streamLogger
|
||||
);
|
||||
await streamLogger.info(
|
||||
`extensionsConfig: ${JSON.stringify(extensionsConfig)}`
|
||||
);
|
||||
|
||||
const collectionType = schemaDocPath.includes("subTables")
|
||||
? "subCollection"
|
||||
: schemaDocPath.includes("groupSchema")
|
||||
? "groupCollection"
|
||||
: "collection";
|
||||
let collectionId = "";
|
||||
let functionName = "";
|
||||
let triggerPath = "";
|
||||
switch (collectionType) {
|
||||
case "collection":
|
||||
collectionId = schemaDocPath.split("/").pop() ?? "";
|
||||
functionName = `"${collectionId}"`;
|
||||
triggerPath = `"${collectionId}/{docId}"`;
|
||||
break;
|
||||
case "subCollection":
|
||||
let pathParentIncrement = 0;
|
||||
triggerPath =
|
||||
'"' +
|
||||
schemaDocPath
|
||||
.replace("_FIRETABLE_/settings/schema/", "")
|
||||
.replace(/subTables/g, function () {
|
||||
pathParentIncrement++;
|
||||
return `{parentDoc${pathParentIncrement}}`;
|
||||
}) +
|
||||
"/{docId}" +
|
||||
'"';
|
||||
functionName =
|
||||
'"' +
|
||||
schemaDocPath
|
||||
.replace("_FIRETABLE_/settings/schema/", "")
|
||||
.replace(/\/subTables\//g, "_") +
|
||||
'"';
|
||||
break;
|
||||
case "groupCollection":
|
||||
collectionId = schemaDocPath.split("/").pop() ?? "";
|
||||
const triggerDepth = schemaData.triggerDepth
|
||||
? schemaData.triggerDepth
|
||||
: 1;
|
||||
triggerPath = "";
|
||||
for (let i = 1; i <= triggerDepth; i++) {
|
||||
triggerPath = triggerPath + `{parentCol${i}}/{parentDoc${i}}/`;
|
||||
}
|
||||
triggerPath = '"' + triggerPath + collectionId + "/" + "{docId}" + '"';
|
||||
functionName = `"CG_${collectionId}${
|
||||
triggerDepth > 1 ? `_D${triggerDepth}` : ""
|
||||
}"`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
await streamLogger.info(
|
||||
`collectionType: ${JSON.stringify(collectionType)}`
|
||||
);
|
||||
|
||||
// generate field types from table meta data
|
||||
const fieldTypes = JSON.stringify(
|
||||
Object.keys(schemaData.columns).reduce((acc, cur) => {
|
||||
const field = schemaData.columns[cur];
|
||||
let fieldType = field.type;
|
||||
if (fieldType === "DERIVATIVE") {
|
||||
fieldType = field.config.renderFieldType;
|
||||
}
|
||||
return {
|
||||
[cur]: fieldType,
|
||||
...acc,
|
||||
};
|
||||
}, {})
|
||||
);
|
||||
await streamLogger.info(`fieldTypes: ${JSON.stringify(fieldTypes)}`);
|
||||
|
||||
const exports: any = {
|
||||
fieldTypes,
|
||||
triggerPath,
|
||||
functionName: functionName.replace(/-/g, "_"),
|
||||
derivativesConfig,
|
||||
initializeConfig,
|
||||
documentSelectConfig,
|
||||
extensionsConfig,
|
||||
};
|
||||
await streamLogger.info(`exports: ${JSON.stringify(exports)}`);
|
||||
|
||||
const fileData = Object.keys(exports).reduce((acc, currKey) => {
|
||||
return `${acc}\nexport const ${currKey} = ${exports[currKey]}`;
|
||||
}, ``);
|
||||
await streamLogger.info(`fileData: ${JSON.stringify(fileData)}`);
|
||||
|
||||
const path = require("path");
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, "../functions/src/functionConfig.ts"),
|
||||
beautify(fileData, { indent_size: 2 })
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
streamLogger.error(error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
83
ft_build/compiler/terminal.ts
Normal file
83
ft_build/compiler/terminal.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as child from "child_process";
|
||||
import admin from "firebase-admin";
|
||||
import { commandErrorHandler, logErrorToDB } from "../utils";
|
||||
|
||||
function execute(command: string, callback: any) {
|
||||
console.log(command);
|
||||
child.exec(command, function (error, stdout, stderr) {
|
||||
console.log({ error, stdout, stderr });
|
||||
callback(stdout);
|
||||
});
|
||||
}
|
||||
|
||||
export const asyncExecute = async (command: string, callback: any) =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
child.exec(command, async function (error, stdout, stderr) {
|
||||
console.log({ error, stdout, stderr });
|
||||
await callback(error, stdout, stderr);
|
||||
resolve(!error);
|
||||
});
|
||||
});
|
||||
|
||||
export const addPackages = async (
|
||||
packages: { name: string; version?: string }[],
|
||||
user: admin.auth.UserRecord,
|
||||
streamLogger
|
||||
) => {
|
||||
const packagesString = packages.reduce((acc, currPackage) => {
|
||||
return `${acc} ${currPackage.name}@${currPackage.version ?? "latest"}`;
|
||||
}, "");
|
||||
if (packagesString.trim().length !== 0) {
|
||||
const success = await asyncExecute(
|
||||
`cd build/functions;yarn add ${packagesString}`,
|
||||
commandErrorHandler(
|
||||
{
|
||||
user,
|
||||
description: "Error adding packages",
|
||||
},
|
||||
streamLogger
|
||||
)
|
||||
);
|
||||
return success;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const addExtensionLib = async (
|
||||
name: string,
|
||||
user: admin.auth.UserRecord,
|
||||
streamLogger
|
||||
) => {
|
||||
try {
|
||||
const { dependencies } = require(`../extensionsLib/${name}`);
|
||||
const packages = Object.keys(dependencies).map((key) => ({
|
||||
name: key,
|
||||
version: dependencies[key],
|
||||
}));
|
||||
const success = await addPackages(packages, user, streamLogger);
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logErrorToDB(
|
||||
{
|
||||
user,
|
||||
errorDescription: "Error parsing dependencies",
|
||||
},
|
||||
streamLogger
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = await asyncExecute(
|
||||
`cp build/extensionsLib/${name}.ts build/functions/src/extensions/${name}.ts`,
|
||||
commandErrorHandler(
|
||||
{
|
||||
user,
|
||||
description: "Error copying extensionsLib",
|
||||
},
|
||||
streamLogger
|
||||
)
|
||||
);
|
||||
return success;
|
||||
};
|
||||
18
ft_build/compiler/tsconfig.json
Normal file
18
ft_build/compiler/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"outDir": "lib",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es6",
|
||||
"lib": ["ESNext"],
|
||||
"strictNullChecks": false
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"include": ["src", "generateConfig.ts"],
|
||||
"ignore": ["extensions", "extensionsLib"]
|
||||
}
|
||||
109
ft_build/extensionsLib/algoliaIndex.ts
Normal file
109
ft_build/extensionsLib/algoliaIndex.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export const dependencies = {
|
||||
algoliasearch: "^4.8.3",
|
||||
};
|
||||
|
||||
const get = (obj, path, defaultValue = undefined) => {
|
||||
const travel = (regexp) =>
|
||||
String.prototype.split
|
||||
.call(path, regexp)
|
||||
.filter(Boolean)
|
||||
.reduce(
|
||||
(res, key) => (res !== null && res !== undefined ? res[key] : res),
|
||||
obj
|
||||
);
|
||||
const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/);
|
||||
return result === undefined || result === obj ? defaultValue : result;
|
||||
};
|
||||
|
||||
const filterSnapshot = (
|
||||
field: { docPath: string; snapshot: any },
|
||||
preservedKeys: string[]
|
||||
) => {
|
||||
return {
|
||||
docPath: field.docPath,
|
||||
...preservedKeys.reduce((acc: any, currentKey: string) => {
|
||||
const value = get(field.snapshot, currentKey);
|
||||
if (value) {
|
||||
return { ...acc, snapshot: { [currentKey]: value, ...acc.snapshot } };
|
||||
} else return acc;
|
||||
}, {}),
|
||||
};
|
||||
};
|
||||
|
||||
// returns object of fieldsToSync
|
||||
const rowReducer = (fieldsToSync, row) =>
|
||||
fieldsToSync.reduce(
|
||||
(
|
||||
acc: any,
|
||||
curr: string | { fieldName: string; snapshotFields: string[] }
|
||||
) => {
|
||||
if (typeof curr === "string") {
|
||||
if (row[curr] && typeof row[curr].toDate === "function") {
|
||||
return {
|
||||
...acc,
|
||||
[curr]: row[curr].toDate().getTime() / 1000,
|
||||
};
|
||||
} else if (row[curr] !== undefined || row[curr] !== null) {
|
||||
return { ...acc, [curr]: row[curr] };
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
} else {
|
||||
if (row[curr.fieldName] && curr.snapshotFields) {
|
||||
return {
|
||||
...acc,
|
||||
[curr.fieldName]: row[curr.fieldName].map((snapshot) =>
|
||||
filterSnapshot(snapshot, curr.snapshotFields)
|
||||
),
|
||||
};
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const significantDifference = (fieldsToSync, change) => {
|
||||
const beforeData = change.before.data();
|
||||
const afterData = change.after.data();
|
||||
return fieldsToSync.reduce((acc, field) => {
|
||||
const key = typeof field === "string" ? field : field.fieldName;
|
||||
if (JSON.stringify(beforeData[key]) !== JSON.stringify(afterData[key]))
|
||||
return true;
|
||||
else return acc;
|
||||
}, false);
|
||||
};
|
||||
|
||||
const algoliaIndex = async (data, extensionContext) => {
|
||||
const { row, objectID, index, fieldsToSync } = data;
|
||||
|
||||
const { triggerType, change } = extensionContext;
|
||||
const record = rowReducer(fieldsToSync, row);
|
||||
const algoliasearch = require("algoliasearch");
|
||||
const { getSecret } = require("../utils");
|
||||
const { appId, adminKey } = await getSecret("algolia");
|
||||
console.log(`algolia app id : ${appId}`);
|
||||
const client = algoliasearch(appId, adminKey);
|
||||
const _index = client.initIndex(index); // initialize algolia index
|
||||
|
||||
switch (triggerType) {
|
||||
case "delete":
|
||||
await _index.deleteObject(objectID);
|
||||
break;
|
||||
case "update":
|
||||
if (
|
||||
significantDifference([...fieldsToSync, "_ft_forcedUpdateAt"], change)
|
||||
) {
|
||||
_index.saveObject({ ...record, objectID });
|
||||
}
|
||||
break;
|
||||
case "create":
|
||||
await _index.saveObject({ ...record, objectID });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
export default algoliaIndex;
|
||||
11
ft_build/extensionsLib/apiCall.ts
Normal file
11
ft_build/extensionsLib/apiCall.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const dependencies = {
|
||||
"node-fetch": "2.6.1",
|
||||
};
|
||||
const api = async (args) => {
|
||||
const { body, url, method, callback } = args;
|
||||
const fetch = require("node-fetch");
|
||||
return fetch(url, { method: method, body: body })
|
||||
.then((res) => res.json())
|
||||
.then((json) => callback(json));
|
||||
};
|
||||
export default api;
|
||||
424
ft_build/extensionsLib/bigqueryIndex.ts
Normal file
424
ft_build/extensionsLib/bigqueryIndex.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
export const dependencies = {
|
||||
"@google-cloud/bigquery": "^5.5.0",
|
||||
};
|
||||
|
||||
const get = (obj, path, defaultValue = undefined) => {
|
||||
const travel = (regexp) =>
|
||||
String.prototype.split
|
||||
.call(path, regexp)
|
||||
.filter(Boolean)
|
||||
.reduce(
|
||||
(res, key) => (res !== null && res !== undefined ? res[key] : res),
|
||||
obj
|
||||
);
|
||||
const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/);
|
||||
return result === undefined || result === obj ? defaultValue : result;
|
||||
};
|
||||
|
||||
const filterSnapshot = (
|
||||
field: { docPath: string; snapshot: any },
|
||||
preservedKeys: string[]
|
||||
) => {
|
||||
return {
|
||||
docPath: field.docPath,
|
||||
...preservedKeys.reduce((acc: any, currentKey: string) => {
|
||||
const value = get(field.snapshot, currentKey);
|
||||
if (value) {
|
||||
return { ...acc, snapshot: { [currentKey]: value, ...acc.snapshot } };
|
||||
} else return acc;
|
||||
}, {}),
|
||||
};
|
||||
};
|
||||
|
||||
// returns object of fieldsToSync
|
||||
const rowReducer = (fieldsToSync, row) =>
|
||||
fieldsToSync.reduce(
|
||||
(
|
||||
acc: any,
|
||||
curr: string | { fieldName: string; snapshotFields: string[] }
|
||||
) => {
|
||||
if (typeof curr === "string") {
|
||||
if (row[curr] && typeof row[curr].toDate === "function") {
|
||||
return {
|
||||
...acc,
|
||||
[curr]: row[curr].toDate().getTime() / 1000,
|
||||
};
|
||||
} else if (row[curr] !== undefined || row[curr] !== null) {
|
||||
return { ...acc, [curr]: row[curr] };
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
} else {
|
||||
if (row[curr.fieldName] && curr.snapshotFields) {
|
||||
return {
|
||||
...acc,
|
||||
[curr.fieldName]: row[curr.fieldName].map((snapshot) =>
|
||||
filterSnapshot(snapshot, curr.snapshotFields)
|
||||
),
|
||||
};
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const significantDifference = (fieldsToSync, change) => {
|
||||
const beforeData = change.before.data();
|
||||
const afterData = change.after.data();
|
||||
return fieldsToSync.reduce((acc, field) => {
|
||||
const key = typeof field === "string" ? field : field.fieldName;
|
||||
if (JSON.stringify(beforeData[key]) !== JSON.stringify(afterData[key]))
|
||||
return true;
|
||||
else return acc;
|
||||
}, false);
|
||||
};
|
||||
|
||||
const transformToSQLData = (value: any, ftType: string) => {
|
||||
if (value === null || value === undefined) {
|
||||
return {
|
||||
value: `null`,
|
||||
type: "STRING",
|
||||
};
|
||||
}
|
||||
|
||||
const sanitise = (x: string) =>
|
||||
x?.replace?.(/\"/g, '\\"')?.replace?.(/\n/g, "\\n") ?? "";
|
||||
|
||||
switch (ftType) {
|
||||
case "SIMPLE_TEXT":
|
||||
case "LONG_TEXT":
|
||||
case "EMAIL":
|
||||
case "PHONE_NUMBER":
|
||||
case "CODE":
|
||||
case "RICH_TEXT":
|
||||
case "ID":
|
||||
case "SINGLE_SELECT":
|
||||
case "URL":
|
||||
return {
|
||||
value: `"${sanitise(value)}"`,
|
||||
type: "STRING",
|
||||
};
|
||||
case "JSON": // JSON
|
||||
case "FILE": // JSON
|
||||
case "IMAGE": // JSON
|
||||
case "USER": // JSON
|
||||
case "COLOR": // JSON
|
||||
case "DOCUMENT_SELECT":
|
||||
case "SERVICE_SELECT":
|
||||
case "ACTION":
|
||||
case "AGGREGATE":
|
||||
case "MULTI_SELECT": // array
|
||||
return {
|
||||
value: `"${sanitise(JSON.stringify(value))}"`,
|
||||
type: "STRING",
|
||||
};
|
||||
case "CHECK_BOX":
|
||||
return {
|
||||
value: value ? `true` : `false`,
|
||||
type: "BOOLEAN",
|
||||
};
|
||||
case "NUMBER":
|
||||
case "PERCENTAGE":
|
||||
case "RATING":
|
||||
case "SLIDER":
|
||||
return {
|
||||
value: Number(value),
|
||||
type: "NUMERIC",
|
||||
};
|
||||
case "DATE":
|
||||
case "DATE_TIME":
|
||||
case "DURATION":
|
||||
if (!value?.toDate) {
|
||||
return {
|
||||
value: `null`,
|
||||
type: "TIMESTAMP",
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: `timestamp("${value?.toDate?.()}")`,
|
||||
type: "TIMESTAMP",
|
||||
};
|
||||
case "LAST":
|
||||
case "STATUS":
|
||||
case "SUB_TABLE":
|
||||
default:
|
||||
// unknown or meaningless to sync
|
||||
return {
|
||||
value: `null`,
|
||||
type: "STRING",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const transformToSQLValue = (ftValue: any, ftType: string) => {
|
||||
const { value } = transformToSQLData(ftValue, ftType);
|
||||
return value;
|
||||
};
|
||||
|
||||
const transformToSQLType = (ftType: string) => {
|
||||
const { type } = transformToSQLData("", ftType);
|
||||
return type;
|
||||
};
|
||||
|
||||
const bigqueryIndex = async (payload, extensionContext) => {
|
||||
const { objectID, index, fieldsToSync, projectID, datasetLocation } = payload;
|
||||
|
||||
const { triggerType, change, fieldTypes } = extensionContext;
|
||||
const record = rowReducer(fieldsToSync, extensionContext.row);
|
||||
const { BigQuery } = require("@google-cloud/bigquery");
|
||||
|
||||
const bigquery = new BigQuery();
|
||||
const _projectID = projectID ?? process.env.GCLOUD_PROJECT;
|
||||
const tableFullName = `${_projectID}.firetable.${index}`;
|
||||
console.log(
|
||||
`projectID: ${_projectID}, index: ${index}, tableFullName: ${tableFullName}`
|
||||
);
|
||||
|
||||
// create dataset with exact name "firetable" if not exists
|
||||
async function preprocessDataset() {
|
||||
const dataset = bigquery.dataset("firetable", {
|
||||
location: datasetLocation ?? "US",
|
||||
});
|
||||
const res = await dataset.exists();
|
||||
const exists = res[0];
|
||||
if (!exists) {
|
||||
console.log("Dataset 'firetable' does not exist, creating dataset...");
|
||||
await dataset.create();
|
||||
console.log("Dataset 'firetable' created.");
|
||||
} else {
|
||||
console.log("Dataset 'firetable' exists.");
|
||||
}
|
||||
}
|
||||
|
||||
async function preprocessTable() {
|
||||
const dataset = bigquery.dataset("firetable");
|
||||
const table = dataset.table(index);
|
||||
const res = await table.exists();
|
||||
const exists = res[0];
|
||||
if (!exists) {
|
||||
console.log(
|
||||
`Table '${index}' does not exist in dataset 'firetable', creating dataset...`
|
||||
);
|
||||
await table.create();
|
||||
console.log(`Table '${index}' created in dataset 'firetable'.`);
|
||||
} else {
|
||||
console.log(`Table ${index} exists in 'firetable'.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function preprocessSchema() {
|
||||
const dataset = bigquery.dataset("firetable");
|
||||
const table = dataset.table(index);
|
||||
const generatedTypes = Object.keys(fieldTypes)
|
||||
.filter((field) => fieldsToSync.includes(field))
|
||||
.reduce((acc, cur) => {
|
||||
return {
|
||||
[cur]: transformToSQLType(fieldTypes[cur]),
|
||||
...acc,
|
||||
};
|
||||
}, {});
|
||||
|
||||
const generatedSchema = [
|
||||
{ name: "objectID", type: "STRING", mode: "REQUIRED" },
|
||||
...Object.keys(generatedTypes).map((key) => {
|
||||
return {
|
||||
name: key,
|
||||
type: generatedTypes[key],
|
||||
mode: "NULLABLE",
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const pushSchema = async () => {
|
||||
console.log("pushing schema:", generatedSchema);
|
||||
const metadata = {
|
||||
schema: generatedSchema,
|
||||
};
|
||||
await table.setMetadata(metadata);
|
||||
console.log("schema pushed.");
|
||||
};
|
||||
|
||||
const existingRes = await table.getMetadata();
|
||||
const existingSchema = existingRes[0].schema?.fields;
|
||||
|
||||
if (!existingSchema) {
|
||||
console.log("Existing schema does not exist, pushing schema...");
|
||||
await pushSchema();
|
||||
return;
|
||||
}
|
||||
|
||||
// check if schema update is needed
|
||||
const objectIDFilter = (field) => field.name !== "objectID";
|
||||
const schemaIdentical =
|
||||
Object.keys(generatedTypes).length ===
|
||||
existingSchema.filter(objectIDFilter).length &&
|
||||
existingSchema
|
||||
.filter(objectIDFilter)
|
||||
.every((field) => generatedTypes[field.name] === field.type);
|
||||
|
||||
if (schemaIdentical) {
|
||||
// no change to schema
|
||||
console.log("Existing schema detected, no update needeed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// check schema compatibility (only new field is accpted)
|
||||
const compatible =
|
||||
Object.keys(generatedTypes).length >
|
||||
existingSchema.filter(objectIDFilter).length &&
|
||||
existingSchema
|
||||
.filter(objectIDFilter)
|
||||
.filter((field) => Object.keys(generatedTypes).includes(field.name))
|
||||
.every((field) => generatedTypes[field.name] === field.type);
|
||||
if (!compatible) {
|
||||
const errorMessage =
|
||||
"New update to field types is not compatible with existing schema. Please manually remove the current bigquery table or update extension index";
|
||||
console.log(errorMessage);
|
||||
throw errorMessage;
|
||||
} else {
|
||||
console.log(
|
||||
"New field types detected and it is compatible with current schema."
|
||||
);
|
||||
}
|
||||
|
||||
// push schema
|
||||
await pushSchema();
|
||||
}
|
||||
|
||||
// return if the objectID exists in bool
|
||||
async function exist() {
|
||||
const query = `SELECT objectID FROM ${tableFullName}
|
||||
WHERE objectID="${objectID}"
|
||||
;`;
|
||||
console.log(query);
|
||||
const res = await bigquery.query(query);
|
||||
const rows = res?.[0];
|
||||
return !!rows?.length;
|
||||
}
|
||||
|
||||
function getTypeKnownRecord(data) {
|
||||
const knownTypes = Object.keys(fieldTypes);
|
||||
const givenKeys = Object.keys(data);
|
||||
const knownKeys = givenKeys.filter((key) => knownTypes.includes(key));
|
||||
const unknownKeys = givenKeys.filter((key) => !knownTypes.includes(key));
|
||||
const knownRecord = Object.keys(data)
|
||||
.filter((key) => knownKeys.includes(key))
|
||||
.reduce((obj, key) => {
|
||||
return {
|
||||
...obj,
|
||||
[key]: data[key],
|
||||
};
|
||||
}, {});
|
||||
|
||||
if (unknownKeys?.length > 0) {
|
||||
console.log(
|
||||
"The following fields do not exist in Firetable and are ignored.",
|
||||
unknownKeys
|
||||
);
|
||||
}
|
||||
|
||||
return knownRecord;
|
||||
}
|
||||
|
||||
async function insert(data) {
|
||||
const keys = Object.keys(data).join(",");
|
||||
const values = Object.keys(data)
|
||||
.map((key) => transformToSQLValue(data[key], fieldTypes[key]))
|
||||
.join(",");
|
||||
const query = `INSERT INTO ${tableFullName}
|
||||
(objectID, ${keys})
|
||||
VALUES ("${objectID}", ${values})
|
||||
;`;
|
||||
console.log(query);
|
||||
await executeQuery(query);
|
||||
}
|
||||
|
||||
// execute a query, if rate limited, sleep and try again until success
|
||||
// ATTENTION: cloud function might timeout the function execution time at 60,000ms
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function executeQuery(query, delayDepth = 1) {
|
||||
try {
|
||||
const res = await bigquery.query(query);
|
||||
console.log(res);
|
||||
} catch (error) {
|
||||
if (
|
||||
error?.errors?.length === 1 &&
|
||||
(error?.errors?.[0]?.reason === "rateLimitExceeded" ||
|
||||
error?.errors?.[0]?.reason === "quotaExceeded")
|
||||
) {
|
||||
const delay = Math.round(
|
||||
Math.floor(Math.random() * 3_000 * (delayDepth % 20) + 1000)
|
||||
);
|
||||
console.log(`API rate limited, try again in ${delay}ms`);
|
||||
await sleep(delay);
|
||||
await executeQuery(query, delayDepth + 1);
|
||||
} else {
|
||||
console.log(error?.errors ?? error);
|
||||
}
|
||||
}
|
||||
if (delayDepth === 1) {
|
||||
console.log("Query finished.");
|
||||
}
|
||||
}
|
||||
|
||||
async function update(data) {
|
||||
const values = Object.keys(data)
|
||||
.map((key) => `${key}=${transformToSQLValue(data[key], fieldTypes[key])}`)
|
||||
.join(",");
|
||||
const query = `UPDATE ${tableFullName}
|
||||
SET ${values}
|
||||
WHERE objectID="${objectID}"
|
||||
;`;
|
||||
console.log(query);
|
||||
await executeQuery(query);
|
||||
}
|
||||
|
||||
async function insertOrUpdate(data) {
|
||||
const objectExists = await exist();
|
||||
if (objectExists) {
|
||||
await update(data);
|
||||
} else {
|
||||
await insert(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const query = `DELETE FROM ${tableFullName}
|
||||
WHERE objectID="${objectID}"
|
||||
;`;
|
||||
console.log(query);
|
||||
await executeQuery(query);
|
||||
}
|
||||
|
||||
// preprocess before starting index logic
|
||||
await preprocessDataset();
|
||||
await preprocessTable();
|
||||
await preprocessSchema();
|
||||
|
||||
// only proceed with fields that have known types
|
||||
const typeKnownRecord = getTypeKnownRecord(record);
|
||||
|
||||
switch (triggerType) {
|
||||
case "delete":
|
||||
await remove();
|
||||
break;
|
||||
case "update":
|
||||
if (
|
||||
significantDifference([...fieldsToSync, "_ft_forcedUpdateAt"], change)
|
||||
) {
|
||||
await insertOrUpdate(typeKnownRecord);
|
||||
} else {
|
||||
console.log("significantDifference is false, no update needed.");
|
||||
}
|
||||
break;
|
||||
case "create":
|
||||
await insertOrUpdate(typeKnownRecord);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
export default bigqueryIndex;
|
||||
55
ft_build/extensionsLib/docSync.ts
Normal file
55
ft_build/extensionsLib/docSync.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export const dependencies = {};
|
||||
|
||||
// returns object of fieldsToSync
|
||||
const rowReducer = (fieldsToSync, row) =>
|
||||
fieldsToSync.reduce((acc: any, curr: string) => {
|
||||
if (row[curr] !== undefined && row[curr] !== null)
|
||||
return { ...acc, [curr]: row[curr] };
|
||||
else return acc;
|
||||
}, {});
|
||||
|
||||
const significantDifference = (fieldsToSync, change) => {
|
||||
const beforeData = change.before.data();
|
||||
const afterData = change.after.data();
|
||||
return fieldsToSync.reduce((acc, field) => {
|
||||
if (JSON.stringify(beforeData[field]) !== JSON.stringify(afterData[field]))
|
||||
return true;
|
||||
else return acc;
|
||||
}, false);
|
||||
};
|
||||
|
||||
const docSync = async (data, extensionContext) => {
|
||||
const { row, targetPath, fieldsToSync } = data;
|
||||
const { triggerType, change } = extensionContext;
|
||||
const record = rowReducer(fieldsToSync, row);
|
||||
const { db } = require("../firebaseConfig");
|
||||
|
||||
switch (triggerType) {
|
||||
case "delete":
|
||||
try {
|
||||
await db.doc(targetPath).delete();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
break;
|
||||
case "update":
|
||||
if (
|
||||
significantDifference([...fieldsToSync, "_ft_forcedUpdateAt"], change)
|
||||
) {
|
||||
try {
|
||||
await db.doc(targetPath).update(record);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "create":
|
||||
await db.doc(targetPath).set(record, { merge: true });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default docSync;
|
||||
33
ft_build/extensionsLib/historySnapshot.ts
Normal file
33
ft_build/extensionsLib/historySnapshot.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const dependencies = {};
|
||||
|
||||
const significantDifference = (fieldsToSync, change) => {
|
||||
const beforeData = change.before.data();
|
||||
const afterData = change.after.data();
|
||||
return fieldsToSync.reduce((acc, field) => {
|
||||
if (JSON.stringify(beforeData[field]) !== JSON.stringify(afterData[field]))
|
||||
return true;
|
||||
else return acc;
|
||||
}, false);
|
||||
};
|
||||
|
||||
const historySnapshot = async (data, extensionContext) => {
|
||||
const { trackedFields } = data;
|
||||
const { triggerType, change } = extensionContext;
|
||||
if (
|
||||
(triggerType === "update" &&
|
||||
significantDifference(trackedFields, change)) ||
|
||||
triggerType === "delete"
|
||||
) {
|
||||
try {
|
||||
await change.before.ref.collection("historySnapshots").add({
|
||||
...change.before.data(),
|
||||
archivedAt: new Date(),
|
||||
archiveEvent: triggerType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
export default historySnapshot;
|
||||
25
ft_build/extensionsLib/mailchimp.ts
Normal file
25
ft_build/extensionsLib/mailchimp.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const dependencies = {
|
||||
"mailchimp-api-v3": "1.15.0",
|
||||
};
|
||||
// method : 'get|post|put|patch|delete'
|
||||
// path :`/lists/${listId}/members`
|
||||
const mailchimp = async (data) => {
|
||||
const { path, method, path_params, body, query } = data;
|
||||
const mailchimpLib = require("mailchimp-api-v3");
|
||||
const utilFns = require("../utils");
|
||||
const mailchimpKey = await utilFns.getSecret("mailchimp");
|
||||
const _mailchimp = new mailchimpLib(mailchimpKey);
|
||||
return new Promise((resolve, reject) => {
|
||||
_mailchimp.request(
|
||||
{
|
||||
method,
|
||||
path,
|
||||
path_params,
|
||||
body,
|
||||
query,
|
||||
},
|
||||
resolve
|
||||
);
|
||||
});
|
||||
};
|
||||
export default mailchimp;
|
||||
131
ft_build/extensionsLib/meiliIndex.ts
Normal file
131
ft_build/extensionsLib/meiliIndex.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
export const dependencies = {
|
||||
meilisearch: "^0.18.1",
|
||||
};
|
||||
|
||||
const get = (obj, path, defaultValue = undefined) => {
|
||||
const travel = (regexp) =>
|
||||
String.prototype.split
|
||||
.call(path, regexp)
|
||||
.filter(Boolean)
|
||||
.reduce(
|
||||
(res, key) => (res !== null && res !== undefined ? res[key] : res),
|
||||
obj
|
||||
);
|
||||
const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/);
|
||||
return result === undefined || result === obj ? defaultValue : result;
|
||||
};
|
||||
|
||||
const filterSnapshot = (
|
||||
field: { docPath: string; snapshot: any },
|
||||
preservedKeys: string[]
|
||||
) => {
|
||||
return {
|
||||
docPath: field.docPath,
|
||||
...preservedKeys.reduce((acc: any, currentKey: string) => {
|
||||
const value = get(field.snapshot, currentKey);
|
||||
if (value) {
|
||||
return { ...acc, snapshot: { [currentKey]: value, ...acc.snapshot } };
|
||||
} else return acc;
|
||||
}, {}),
|
||||
};
|
||||
};
|
||||
|
||||
// returns object of fieldsToSync
|
||||
const rowReducer = (fieldsToSync, row) =>
|
||||
fieldsToSync.reduce(
|
||||
(
|
||||
acc: any,
|
||||
curr: string | { fieldName: string; snapshotFields: string[] }
|
||||
) => {
|
||||
if (typeof curr === "string") {
|
||||
if (row[curr] && typeof row[curr].toDate === "function") {
|
||||
return {
|
||||
...acc,
|
||||
[curr]: row[curr].toDate().getTime() / 1000,
|
||||
};
|
||||
} else if (row[curr] !== undefined || row[curr] !== null) {
|
||||
return { ...acc, [curr]: row[curr] };
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
} else {
|
||||
if (row[curr.fieldName] && curr.snapshotFields) {
|
||||
return {
|
||||
...acc,
|
||||
[curr.fieldName]: row[curr.fieldName].map((snapshot) =>
|
||||
filterSnapshot(snapshot, curr.snapshotFields)
|
||||
),
|
||||
};
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const significantDifference = (fieldsToSync, change) => {
|
||||
const beforeData = change.before.data();
|
||||
const afterData = change.after.data();
|
||||
return fieldsToSync.reduce((acc, field) => {
|
||||
const key = typeof field === "string" ? field : field.fieldName;
|
||||
if (JSON.stringify(beforeData[key]) !== JSON.stringify(afterData[key]))
|
||||
return true;
|
||||
else return acc;
|
||||
}, false);
|
||||
};
|
||||
|
||||
const meiliIndex = async (data, extensionContext) => {
|
||||
const { row, objectID, index, fieldsToSync } = data;
|
||||
|
||||
const { triggerType, change } = extensionContext;
|
||||
const record = rowReducer(fieldsToSync, row);
|
||||
const { MeiliSearch } = require("meilisearch");
|
||||
const { getSecret } = require("../utils");
|
||||
const meiliConfig = await getSecret("meilisearch");
|
||||
console.log(`meilisearch host : ${meiliConfig.host}, index: ${index}`);
|
||||
const client = new MeiliSearch(meiliConfig);
|
||||
const _index = client.index(index);
|
||||
|
||||
let res;
|
||||
switch (triggerType) {
|
||||
case "delete":
|
||||
console.log("Deleting...");
|
||||
res = await _index.deleteDocument(objectID);
|
||||
break;
|
||||
case "update":
|
||||
if (
|
||||
significantDifference([...fieldsToSync, "_ft_forcedUpdateAt"], change)
|
||||
) {
|
||||
console.log("Updating...");
|
||||
res = await _index.updateDocuments([
|
||||
{
|
||||
id: objectID,
|
||||
...record,
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
case "create":
|
||||
console.log("Creating...");
|
||||
res = await _index.addDocuments([
|
||||
{
|
||||
id: objectID,
|
||||
...record,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
console.log("No match.");
|
||||
break;
|
||||
}
|
||||
console.log("Checking status...");
|
||||
if (res?.updateId) {
|
||||
console.log("Querying status...");
|
||||
const status = await client.index(index).getUpdateStatus(res.updateId);
|
||||
console.log("Status:", status);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
export default meiliIndex;
|
||||
13
ft_build/extensionsLib/sendgridEmail.ts
Normal file
13
ft_build/extensionsLib/sendgridEmail.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const dependencies = {
|
||||
"@sendgrid/mail": "^7.4.2",
|
||||
};
|
||||
const sendgridEmail = async (data) => {
|
||||
const { msg } = data;
|
||||
const sgMail = require("@sendgrid/mail");
|
||||
const utilFns = require("../utils");
|
||||
sgMail.setSubstitutionWrappers("{{", "}}");
|
||||
const sendgridKey = await utilFns.getSecret("sendgrid");
|
||||
sgMail.setApiKey(sendgridKey);
|
||||
return sgMail.send(msg);
|
||||
};
|
||||
export default sendgridEmail;
|
||||
92
ft_build/extensionsLib/slackMessage.ts
Normal file
92
ft_build/extensionsLib/slackMessage.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
{ channels?:string[], emails?:string[], text?:string, blocks?:any,attachments?:any }
|
||||
*/
|
||||
|
||||
export const dependencies = {
|
||||
"@slack/web-api": "^6.0.0",
|
||||
};
|
||||
|
||||
const initSlack = async () => {
|
||||
const { getSecret } = require("../utils");
|
||||
const { token } = await getSecret("slack");
|
||||
const { WebClient } = require("@slack/web-api");
|
||||
return new WebClient(token);
|
||||
};
|
||||
|
||||
const messageByChannel = (slackClient) => async ({
|
||||
text,
|
||||
channel,
|
||||
blocks,
|
||||
attachments,
|
||||
}: {
|
||||
channel: string;
|
||||
text: string;
|
||||
blocks: any[];
|
||||
attachments: any[];
|
||||
}) =>
|
||||
await slackClient.chat.postMessage({
|
||||
text,
|
||||
channel,
|
||||
blocks,
|
||||
attachments,
|
||||
});
|
||||
|
||||
const messageByEmail = (slackClient) => async ({
|
||||
email,
|
||||
text,
|
||||
blocks,
|
||||
attachments,
|
||||
}: {
|
||||
email: string;
|
||||
text: string;
|
||||
blocks: any[];
|
||||
attachments: any[];
|
||||
}) => {
|
||||
try {
|
||||
const user = await slackClient.users.lookupByEmail({ email });
|
||||
if (user.ok) {
|
||||
const channel = user.user.id;
|
||||
return await messageByChannel(slackClient)({
|
||||
text,
|
||||
blocks,
|
||||
attachments,
|
||||
channel,
|
||||
});
|
||||
} else {
|
||||
return await false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${error} maybe${email} is not on slack`);
|
||||
console.log(`${error}`);
|
||||
return await false;
|
||||
}
|
||||
};
|
||||
|
||||
const slackMessage = async (data) => {
|
||||
const slackClient = await initSlack();
|
||||
const { channels, emails, text, blocks, attachments } = data;
|
||||
if (channels) {
|
||||
const messages = channels.map((channel: string) =>
|
||||
messageByChannel(slackClient)({
|
||||
text,
|
||||
blocks: blocks ?? [],
|
||||
channel,
|
||||
attachments,
|
||||
})
|
||||
);
|
||||
await Promise.all(messages);
|
||||
}
|
||||
if (emails) {
|
||||
const messages = emails.map((email: string) =>
|
||||
messageByEmail(slackClient)({
|
||||
text: text,
|
||||
blocks: blocks ?? [],
|
||||
email,
|
||||
attachments,
|
||||
})
|
||||
);
|
||||
await Promise.all(messages);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
export default slackMessage;
|
||||
6
ft_build/extensionsLib/task.ts
Normal file
6
ft_build/extensionsLib/task.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const dependencies = {};
|
||||
const task = () => {
|
||||
// the code logic of task extension should be defined in extension body
|
||||
// and the return value of extension body is ignored
|
||||
};
|
||||
export default task;
|
||||
13
ft_build/extensionsLib/twilioMessage.ts
Normal file
13
ft_build/extensionsLib/twilioMessage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const dependencies = {
|
||||
twilio: "3.56.0",
|
||||
};
|
||||
const twilioMessage = async (data) => {
|
||||
const utilFns = require("../utils");
|
||||
const { accountSid, authToken } = await utilFns.getSecret("twilio");
|
||||
const client = require("twilio")(accountSid, authToken);
|
||||
const { body, from, to } = data;
|
||||
return client.messages
|
||||
.create({ body, from, to })
|
||||
.then((message) => console.log(message.sid));
|
||||
};
|
||||
export default twilioMessage;
|
||||
68
ft_build/functions/src/extensions/index.ts
Normal file
68
ft_build/functions/src/extensions/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as functions from "firebase-functions";
|
||||
import utilFns, { hasRequiredFields, getTriggerType } from "../utils";
|
||||
import { db, auth, storage } from "../firebaseConfig";
|
||||
|
||||
const extension = (extensionConfig, fieldTypes) => async (
|
||||
change: functions.Change<functions.firestore.DocumentSnapshot>,
|
||||
context: functions.EventContext
|
||||
) => {
|
||||
const beforeData = change.before?.data();
|
||||
const afterData = change.after?.data();
|
||||
const ref = change.after ? change.after.ref : change.before.ref;
|
||||
const triggerType = getTriggerType(change);
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
triggers,
|
||||
conditions,
|
||||
requiredFields,
|
||||
extensionBody,
|
||||
} = extensionConfig;
|
||||
const extensionContext = {
|
||||
row: triggerType === "delete" ? beforeData : afterData,
|
||||
ref,
|
||||
db,
|
||||
auth,
|
||||
change,
|
||||
triggerType,
|
||||
extensionConfig,
|
||||
utilFns,
|
||||
fieldTypes,
|
||||
storage,
|
||||
};
|
||||
if (!triggers.includes(triggerType)) return false; //check if trigger type is included in the extension
|
||||
if (
|
||||
triggerType !== "delete" &&
|
||||
requiredFields &&
|
||||
requiredFields.length !== 0 &&
|
||||
!hasRequiredFields(requiredFields, afterData)
|
||||
) {
|
||||
console.log("requiredFields are ", requiredFields, "type is", type);
|
||||
return false; // check if it hase required fields for the extension to run
|
||||
}
|
||||
const dontRun = conditions
|
||||
? !(typeof conditions === "function"
|
||||
? await conditions(extensionContext)
|
||||
: conditions)
|
||||
: false; //
|
||||
|
||||
console.log(`name: "${name}", type: "${type}", dontRun: ${dontRun}`);
|
||||
|
||||
if (dontRun) return false;
|
||||
const extensionData = await extensionBody(extensionContext);
|
||||
console.log(`extensionData: ${JSON.stringify(extensionData)}`);
|
||||
const extensionFn = require(`./${type}`).default;
|
||||
await extensionFn(extensionData, extensionContext);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const { name, type } = extensionConfig;
|
||||
console.log(
|
||||
`error in ${name} extension of type ${type}, on ${context.eventType} in Doc ${context.resource.name}`
|
||||
);
|
||||
console.error(err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
export default extension;
|
||||
72
ft_build/functions/src/index.ts
Normal file
72
ft_build/functions/src/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as functions from "firebase-functions";
|
||||
import derivative from "./derivatives";
|
||||
import extension from "./extensions";
|
||||
import {
|
||||
functionName,
|
||||
triggerPath,
|
||||
derivativesConfig,
|
||||
documentSelectConfig,
|
||||
extensionsConfig,
|
||||
initializeConfig,
|
||||
fieldTypes,
|
||||
} from "./functionConfig";
|
||||
|
||||
import { getTriggerType, changedDocPath } from "./utils";
|
||||
import propagate from "./propagates";
|
||||
import initialize from "./initialize";
|
||||
export const FT = {
|
||||
[functionName]: functions.firestore
|
||||
.document(triggerPath)
|
||||
.onWrite(async (change, context) => {
|
||||
const triggerType = getTriggerType(change);
|
||||
let promises: Promise<any>[] = [];
|
||||
const extensionPromises = extensionsConfig
|
||||
.filter((extensionConfig) =>
|
||||
extensionConfig.triggers.includes(triggerType)
|
||||
)
|
||||
.map((extensionConfig) =>
|
||||
extension(extensionConfig, fieldTypes)(change, context)
|
||||
);
|
||||
console.log(
|
||||
`#${
|
||||
extensionPromises.length
|
||||
} extensions will be evaluated on ${triggerType} of ${changedDocPath(
|
||||
change
|
||||
)}`
|
||||
);
|
||||
promises = extensionPromises;
|
||||
const propagatePromise = propagate(
|
||||
change,
|
||||
documentSelectConfig,
|
||||
triggerType
|
||||
);
|
||||
promises.push(propagatePromise);
|
||||
try {
|
||||
let docUpdates = {};
|
||||
if (triggerType === "update") {
|
||||
try {
|
||||
docUpdates = await derivative(derivativesConfig)(change);
|
||||
} catch (err) {
|
||||
console.log(`caught error: ${err}`);
|
||||
}
|
||||
} else if (triggerType === "create") {
|
||||
try {
|
||||
const initialData = await initialize(initializeConfig)(
|
||||
change.after
|
||||
);
|
||||
const derivativeData = await derivative(derivativesConfig)(change);
|
||||
docUpdates = { ...initialData, ...derivativeData };
|
||||
} catch (err) {
|
||||
console.log(`caught error: ${err}`);
|
||||
}
|
||||
}
|
||||
if (Object.keys(docUpdates).length !== 0) {
|
||||
promises.push(change.after.ref.update(docUpdates));
|
||||
}
|
||||
const result = await Promise.allSettled(promises);
|
||||
console.log(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
console.log(`caught error: ${err}`);
|
||||
}
|
||||
}),
|
||||
};
|
||||
18
ft_build/functions/tsconfig.json
Normal file
18
ft_build/functions/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"outDir": "lib",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es6",
|
||||
"lib": ["ESNext"],
|
||||
"strictNullChecks": false
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"include": ["src", "generateConfig.ts"],
|
||||
"ignore": ["extensions"]
|
||||
}
|
||||
34
ft_build/package.json
Normal file
34
ft_build/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "ft-functions-builder",
|
||||
"description": "Manages the build and deployment of Firetable cloud functions",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "yarn build && node build",
|
||||
"build": "rm -rf build && tsc --project ./ && cp -r functions build && cp -r extensionsLib build",
|
||||
"deploy": "./deploy.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": "14"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.19.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"firebase-admin": "^9.2.0",
|
||||
"firebase-functions": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/node": "^14.14.33",
|
||||
"firebase-tools": "^8.7.0",
|
||||
"husky": "^4.2.5",
|
||||
"js-beautify": "^1.13.0",
|
||||
"prettier": "^2.1.1",
|
||||
"pretty-quick": "^3.0.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"tslint": "^6.1.0",
|
||||
"typescript": "^4.2.3"
|
||||
}
|
||||
}
|
||||
20
ft_build/tsconfig.json
Normal file
20
ft_build/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"rootDir": "./",
|
||||
"outDir": "./build",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": false,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": false,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["ESNext"],
|
||||
"strictNullChecks": false
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"exclude": ["functions", "build"],
|
||||
"include": ["*.ts", "firebase.json", "extensionsLib"]
|
||||
}
|
||||
198
ft_build/utils.ts
Normal file
198
ft_build/utils.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { db } from "./firebaseConfig";
|
||||
import admin from "firebase-admin";
|
||||
|
||||
function firetableUser(user: admin.auth.UserRecord) {
|
||||
return {
|
||||
displayName: user?.displayName,
|
||||
email: user?.email,
|
||||
uid: user?.uid,
|
||||
emailVerified: user?.emailVerified,
|
||||
photoURL: user?.photoURL,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function insertErrorRecordToDB(errorRecord: object) {
|
||||
await db.collection("_FT_ERRORS").add(errorRecord);
|
||||
}
|
||||
|
||||
async function insertErrorToStreamer(errorRecord: object, streamLogger) {
|
||||
let errorString = "";
|
||||
for (const key of [
|
||||
"command",
|
||||
"description",
|
||||
"functionConfigTs",
|
||||
"extensionsConfig",
|
||||
"stderr",
|
||||
"errorStackTrace",
|
||||
]) {
|
||||
const value = errorRecord[key];
|
||||
if (value) {
|
||||
errorString += `\n\n${key}: ${value}`;
|
||||
}
|
||||
}
|
||||
await streamLogger.error(errorString);
|
||||
}
|
||||
|
||||
function commandErrorHandler(
|
||||
meta: {
|
||||
user: admin.auth.UserRecord;
|
||||
description?: string;
|
||||
functionConfigTs?: string;
|
||||
extensionsConfig?: string;
|
||||
},
|
||||
streamLogger
|
||||
) {
|
||||
return async function (error, stdout, stderr) {
|
||||
await streamLogger.info(stdout);
|
||||
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorRecord = {
|
||||
errorType: "commandError",
|
||||
ranBy: firetableUser(meta.user),
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
stdout: stdout ?? "",
|
||||
stderr: stderr ?? "",
|
||||
errorStackTrace: error?.stack ?? "",
|
||||
command: error?.cmd ?? "",
|
||||
description: meta?.description ?? "",
|
||||
functionConfigTs: meta?.functionConfigTs ?? "",
|
||||
extensionsConfig: meta?.extensionsConfig ?? "",
|
||||
};
|
||||
await insertErrorToStreamer(errorRecord, streamLogger);
|
||||
insertErrorRecordToDB(errorRecord);
|
||||
};
|
||||
}
|
||||
|
||||
async function logErrorToDB(
|
||||
data: {
|
||||
errorDescription: string;
|
||||
errorExtraInfo?: string;
|
||||
errorTraceStack?: string;
|
||||
user: admin.auth.UserRecord;
|
||||
extensionsConfig?: string;
|
||||
},
|
||||
streamLogger?
|
||||
) {
|
||||
console.error(data.errorDescription);
|
||||
|
||||
const errorRecord = {
|
||||
errorType: "codeError",
|
||||
ranBy: firetableUser(data.user),
|
||||
description: data.errorDescription,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
extensionsConfig: data?.extensionsConfig ?? "",
|
||||
errorExtraInfo: data?.errorExtraInfo ?? "",
|
||||
errorStackTrace: data?.errorTraceStack ?? "",
|
||||
};
|
||||
if (streamLogger) {
|
||||
await insertErrorToStreamer(errorRecord, streamLogger);
|
||||
}
|
||||
insertErrorRecordToDB(errorRecord);
|
||||
}
|
||||
|
||||
function parseExtensionsConfig(
|
||||
extensions: string | undefined,
|
||||
user: admin.auth.UserRecord,
|
||||
streamLogger
|
||||
) {
|
||||
if (extensions) {
|
||||
try {
|
||||
// remove leading "extensions.config(" and trailing ")"
|
||||
return extensions
|
||||
.replace(/^(\s*)extensions.config\(/, "")
|
||||
.replace(/\);?\s*$/, "");
|
||||
} catch (error) {
|
||||
logErrorToDB(
|
||||
{
|
||||
errorDescription: "Extensions is not wrapped with extensions.config",
|
||||
errorTraceStack: error.stack,
|
||||
user,
|
||||
extensionsConfig: extensions,
|
||||
},
|
||||
streamLogger
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return "[]";
|
||||
}
|
||||
|
||||
async function createStreamLogger(tableConfigPath: string) {
|
||||
const startTimeStamp = Date.now();
|
||||
const fullLog: {
|
||||
log: string;
|
||||
level: "info" | "error";
|
||||
timestamp: number;
|
||||
}[] = [];
|
||||
const logRef = db
|
||||
.doc(tableConfigPath)
|
||||
.collection("ftBuildLogs")
|
||||
.doc(startTimeStamp.toString());
|
||||
await logRef.set({ startTimeStamp, status: "BUILDING" });
|
||||
|
||||
console.log(
|
||||
`streamLogger created. tableConfigPath: ${tableConfigPath}, startTimeStamp: ${startTimeStamp}`
|
||||
);
|
||||
|
||||
return {
|
||||
info: async (log: string) => {
|
||||
console.log(log);
|
||||
fullLog.push({
|
||||
log,
|
||||
level: "info",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await logRef.update({
|
||||
fullLog,
|
||||
});
|
||||
},
|
||||
error: async (log: string) => {
|
||||
console.error(log);
|
||||
fullLog.push({
|
||||
log,
|
||||
level: "error",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await logRef.update({
|
||||
fullLog,
|
||||
});
|
||||
},
|
||||
end: async () => {
|
||||
const logsDoc = await logRef.get();
|
||||
const errorLog = logsDoc
|
||||
.get("fullLog")
|
||||
.filter((log) => log.level === "error");
|
||||
if (errorLog.length !== 0) {
|
||||
console.log("streamLogger marked as FAIL");
|
||||
await logRef.update({
|
||||
status: "FAIL",
|
||||
failTimeStamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
console.log("streamLogger marked as SUCCESS");
|
||||
await logRef.update({
|
||||
status: "SUCCESS",
|
||||
successTimeStamp: Date.now(),
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: async () => {
|
||||
console.log("streamLogger marked as FAIL");
|
||||
await logRef.update({
|
||||
status: "FAIL",
|
||||
failTimeStamp: Date.now(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
commandErrorHandler,
|
||||
logErrorToDB,
|
||||
parseExtensionsConfig,
|
||||
createStreamLogger,
|
||||
};
|
||||
1264
www/.yarn/releases/yarn-1.22.5.cjs
vendored
1264
www/.yarn/releases/yarn-1.22.5.cjs
vendored
File diff suppressed because it is too large
Load Diff
@@ -16,15 +16,19 @@ function AvailableValueTag({ label, details }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* TODO implement parameter "tags" that defines available tags and values
|
||||
{
|
||||
row: "You have access to the object 'row' at...",
|
||||
ref: "...",
|
||||
...: ...
|
||||
export interface ICodeEditorHelperProps {
|
||||
docLink: string;
|
||||
additionalVariables?: {
|
||||
key: string;
|
||||
description: string;
|
||||
}[];
|
||||
}
|
||||
*/
|
||||
export default function CodeEditorHelper({ docLink }) {
|
||||
const {} = useFiretableContext();
|
||||
|
||||
export default function CodeEditorHelper({
|
||||
docLink,
|
||||
additionalVariables,
|
||||
}: ICodeEditorHelperProps) {
|
||||
const { tableState } = useFiretableContext();
|
||||
const availableVariables = [
|
||||
{
|
||||
key: "row",
|
||||
@@ -55,7 +59,7 @@ export default function CodeEditorHelper({ docLink }) {
|
||||
<Box marginBottom={1} display="flex" justifyContent="space-between">
|
||||
<Box>
|
||||
You have access to:{" "}
|
||||
{availableVariables.map((v) => (
|
||||
{availableVariables.concat(additionalVariables ?? []).map((v) => (
|
||||
<AvailableValueTag label={v.key} details={v.description} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
import React, { useState } from "react";
|
||||
import moment from "moment";
|
||||
import { extensionTypes, IExtension, IExtensionType } from "./utils";
|
||||
import EmptyState from "components/EmptyState";
|
||||
import AddIcon from "@material-ui/icons/Add";
|
||||
import EmptyIcon from "@material-ui/icons/AddBox";
|
||||
import DuplicateIcon from "@material-ui/icons/FileCopy";
|
||||
import EditIcon from "@material-ui/icons/Edit";
|
||||
import DeleteIcon from "@material-ui/icons/DeleteForever";
|
||||
import { useRef } from "react";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
hoverableEmptyState: {
|
||||
borderRadius: theme.spacing(1),
|
||||
cursor: "pointer",
|
||||
padding: theme.spacing(2),
|
||||
"&:hover": {
|
||||
background: theme.palette.background.paper,
|
||||
},
|
||||
},
|
||||
divider: {
|
||||
margin: theme.spacing(1, 0),
|
||||
},
|
||||
extensionName: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
extensionType: {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
avatar: {
|
||||
marginRight: theme.spacing(1),
|
||||
width: theme.spacing(4),
|
||||
height: theme.spacing(4),
|
||||
},
|
||||
extensionList: {
|
||||
height: "50vh",
|
||||
overflowY: "scroll",
|
||||
},
|
||||
}));
|
||||
|
||||
export interface IExtensionListProps {
|
||||
extensions: IExtension[];
|
||||
handleAddExtension: (type: IExtensionType) => void;
|
||||
handleUpdateActive: (index: number, active: boolean) => void;
|
||||
handleDuplicate: (index: number) => void;
|
||||
handleEdit: (index: number) => void;
|
||||
handleDelete: (index: number) => void;
|
||||
}
|
||||
|
||||
export default function ExtensionList({
|
||||
extensions,
|
||||
handleAddExtension,
|
||||
handleUpdateActive,
|
||||
handleDuplicate,
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}: IExtensionListProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const addButtonRef = useRef(null);
|
||||
const classes = useStyles();
|
||||
|
||||
const activeExtensionCount = extensions.filter(
|
||||
(extension) => extension.active
|
||||
).length;
|
||||
|
||||
const handleAddButton = () => {
|
||||
setAnchorEl(addButtonRef.current);
|
||||
};
|
||||
|
||||
const handleChooseAddType = (type: IExtensionType) => {
|
||||
handleClose();
|
||||
handleAddExtension(type);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
marginTop={"0px !important"}
|
||||
>
|
||||
<Typography variant="overline">
|
||||
EXTENSION ({activeExtensionCount}/{extensions.length})
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAddButton}
|
||||
ref={addButtonRef}
|
||||
>
|
||||
ADD EXTENTION
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
keepMounted
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{extensionTypes.map((type) => (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleChooseAddType(type);
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
<Box className={classes.extensionList}>
|
||||
{extensions.length === 0 && (
|
||||
<EmptyState
|
||||
message="Add your first extension"
|
||||
description={
|
||||
"When you add extentions, your extentions should be shown here."
|
||||
}
|
||||
Icon={EmptyIcon}
|
||||
className={classes.hoverableEmptyState}
|
||||
onClick={handleAddButton}
|
||||
/>
|
||||
)}
|
||||
{extensions.map((extensionObject, index) => {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Typography variant="body2" className={classes.extensionName}>
|
||||
{extensionObject.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="overline"
|
||||
className={classes.extensionType}
|
||||
>
|
||||
{extensionObject.type}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Tooltip
|
||||
title={extensionObject.active ? "Deactivate" : "Activate"}
|
||||
>
|
||||
<Switch
|
||||
color="primary"
|
||||
checked={extensionObject.active}
|
||||
onClick={() => {
|
||||
handleUpdateActive(index, !extensionObject.active);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={"Edit"}>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
handleEdit(index);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={"Duplicate"}>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
handleDuplicate(index);
|
||||
}}
|
||||
>
|
||||
<DuplicateIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={"Delete"}>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
handleDelete(index);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Tooltip
|
||||
title={`Last updated by ${
|
||||
extensionObject.lastEditor.displayName
|
||||
} on ${moment(extensionObject.lastEditor.lastUpdate).format(
|
||||
"LLLL"
|
||||
)}`}
|
||||
>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Avatar
|
||||
alt="profile"
|
||||
src={extensionObject.lastEditor.photoURL}
|
||||
className={classes.avatar}
|
||||
/>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{moment(
|
||||
extensionObject.lastEditor.lastUpdate
|
||||
).calendar()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{index + 1 !== extensions.length && (
|
||||
<Divider light className={classes.divider} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useState } from "react";
|
||||
import _isEqual from "lodash/isEqual";
|
||||
import { sparkToExtensionObjects } from "./utils";
|
||||
import Modal from "components/Modal";
|
||||
import { useFiretableContext } from "contexts/FiretableContext";
|
||||
import { useAppContext } from "contexts/AppContext";
|
||||
import firebase from "firebase/app";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
modalRoot: {
|
||||
height: `calc(100vh - 200px)`,
|
||||
},
|
||||
download: {
|
||||
maxWidth: 320,
|
||||
marginTop: theme.spacing(0.5),
|
||||
},
|
||||
}));
|
||||
|
||||
export interface IExtensionMigrationProps {
|
||||
handleClose: () => void;
|
||||
handleUpgradeComplete: () => void;
|
||||
}
|
||||
|
||||
export default function ExtensionMigration({
|
||||
handleClose,
|
||||
handleUpgradeComplete,
|
||||
}: IExtensionMigrationProps) {
|
||||
const classes = useStyles();
|
||||
const { tableState, tableActions } = useFiretableContext();
|
||||
const appContext = useAppContext();
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [isUpgrading, setIsUpgrading] = useState(false);
|
||||
|
||||
const currentEditor = () => ({
|
||||
displayName: appContext?.currentUser?.displayName ?? "Unknown user",
|
||||
photoURL: appContext?.currentUser?.photoURL ?? "",
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
|
||||
const downloadSparkFile = () => {
|
||||
const tablePathTokens =
|
||||
tableState?.tablePath?.split("/").filter(function (_, i) {
|
||||
// replace IDs with dash that appears at even indexes
|
||||
return i % 2 === 0;
|
||||
}) ?? [];
|
||||
const tablePath = tablePathTokens.join("-");
|
||||
|
||||
// https://medium.com/front-end-weekly/text-file-download-in-react-a8b28a580c0d
|
||||
const element = document.createElement("a");
|
||||
const file = new Blob([tableState?.config.sparks ?? ""], {
|
||||
type: "text/plain;charset=utf-8",
|
||||
});
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `sparks-${tablePath}.ts`;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
setIsSaved(true);
|
||||
};
|
||||
|
||||
const upgradeToExtensions = () => {
|
||||
setIsUpgrading(true);
|
||||
const extensionObjects = sparkToExtensionObjects(
|
||||
tableState?.config.sparks ?? "[]",
|
||||
currentEditor()
|
||||
);
|
||||
console.log(extensionObjects);
|
||||
tableActions?.table.updateConfig("extensionObjects", extensionObjects);
|
||||
tableActions?.table.updateConfig(
|
||||
"sparks",
|
||||
firebase.firestore.FieldValue.delete()
|
||||
);
|
||||
setTimeout(handleUpgradeComplete, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
title={"Extensions Migration Guide"}
|
||||
children={
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
className={classes.modalRoot}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
We have upgraded spark editor to extension editor with a better UI.
|
||||
The old sparks are not compatible with this change, however you can
|
||||
use this tool to upgrade your sparks.
|
||||
</Typography>
|
||||
<br />
|
||||
|
||||
<Typography variant="overline">
|
||||
1. Save your sparks for backup
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
You must save your sparks before upgrade.
|
||||
</Typography>
|
||||
<Button
|
||||
className={classes.download}
|
||||
variant="contained"
|
||||
color={isSaved ? "secondary" : "primary"}
|
||||
onClick={downloadSparkFile}
|
||||
>
|
||||
Save sparks
|
||||
</Button>
|
||||
<br />
|
||||
|
||||
<Typography variant="overline">
|
||||
2. Upgrade sparks to extensions
|
||||
</Typography>
|
||||
{/* TODO add documentation link */}
|
||||
<Typography variant="body2">
|
||||
After the upgrade, your old sparks will be removed from database.
|
||||
And you might need to do some manual change to the code. See this
|
||||
documentation for more information.
|
||||
</Typography>
|
||||
<Button
|
||||
className={classes.download}
|
||||
variant="contained"
|
||||
onClick={upgradeToExtensions}
|
||||
disabled={!isSaved || isUpgrading}
|
||||
startIcon={
|
||||
isUpgrading && <CircularProgress size={20} thickness={5} />
|
||||
}
|
||||
>
|
||||
{isUpgrading && "Upgrading..."}
|
||||
{!isUpgrading && "Upgrade to extensions"}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import React, { useState } from "react";
|
||||
import _isEqual from "lodash/isEqual";
|
||||
import useStateRef from "react-usestateref";
|
||||
import { IExtension, triggerTypes } from "./utils";
|
||||
import Modal from "components/Modal";
|
||||
import CodeEditorHelper from "components/CodeEditorHelper";
|
||||
import { useConfirmation } from "components/ConfirmationDialog";
|
||||
import CodeEditor from "../../editors/CodeEditor";
|
||||
import { useFiretableContext } from "contexts/FiretableContext";
|
||||
import BackIcon from "@material-ui/icons/ArrowBack";
|
||||
import AddIcon from "@material-ui/icons/AddBox";
|
||||
import DeleteIcon from "@material-ui/icons/RemoveCircle";
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Grid,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Switch,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
|
||||
const additionalVariables = [
|
||||
{
|
||||
key: "change",
|
||||
description:
|
||||
"you can pass in field name to change.before.get() or change.after.get() to get changes",
|
||||
},
|
||||
{
|
||||
key: "triggerType",
|
||||
description: "triggerType indicates the type of the extention invocation",
|
||||
},
|
||||
{
|
||||
key: "fieldTypes",
|
||||
description:
|
||||
"fieldTypes is a map of all fields and its corresponding Firetable column type",
|
||||
},
|
||||
{
|
||||
key: "extensionConfig",
|
||||
description: "the configuration object of this extension",
|
||||
},
|
||||
];
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
modalRoot: {
|
||||
height: `calc(100vh - 250px)`,
|
||||
},
|
||||
metaRoot: {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
tabWrapper: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
tabRoot: {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
tabPanel: {
|
||||
padding: 0,
|
||||
},
|
||||
label: {
|
||||
marginTop: theme.spacing(2),
|
||||
marginBottom: theme.spacing(1),
|
||||
display: "block",
|
||||
},
|
||||
hoverable: {
|
||||
borderRadius: theme.spacing(1),
|
||||
cursor: "pointer",
|
||||
padding: theme.spacing(1, 0),
|
||||
"&:hover": {
|
||||
background: theme.palette.background.paper,
|
||||
},
|
||||
},
|
||||
requiredFields: {
|
||||
maxHeight: `max(300px, 30vh)`,
|
||||
overflowY: "scroll",
|
||||
},
|
||||
addField: {
|
||||
paddingLeft: 13, // align icons to the left
|
||||
},
|
||||
removeField: {
|
||||
marginLeft: -3, // align icons to the left
|
||||
},
|
||||
}));
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: any;
|
||||
value: any;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
style={{ height: "100%" }}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box
|
||||
style={{ height: "100%" }}
|
||||
p={3}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface IExtensionModalProps {
|
||||
handleClose: () => void;
|
||||
handleAdd: (extensionObject: IExtension) => void;
|
||||
handleUpdate: (extensionObject: IExtension) => void;
|
||||
mode: "add" | "update";
|
||||
extensionObject: IExtension;
|
||||
}
|
||||
|
||||
export default function ExtensionModal({
|
||||
handleClose,
|
||||
handleAdd,
|
||||
handleUpdate,
|
||||
mode,
|
||||
extensionObject: initialObject,
|
||||
}: IExtensionModalProps) {
|
||||
const { requestConfirmation } = useConfirmation();
|
||||
const [extensionObject, setExtensionObject] = useState<IExtension>(
|
||||
initialObject
|
||||
);
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const [validation, setValidation, validationRef] = useStateRef({
|
||||
condition: true,
|
||||
extensionBody: true,
|
||||
});
|
||||
const [
|
||||
conditionEditorActive,
|
||||
setConditionEditorActive,
|
||||
conditionEditorActiveRef,
|
||||
] = useStateRef(false);
|
||||
const [
|
||||
bodyEditorActive,
|
||||
setBodyEditorActive,
|
||||
bodyEditorActiveRef,
|
||||
] = useStateRef(false);
|
||||
const classes = useStyles();
|
||||
const { tableState } = useFiretableContext();
|
||||
const columns = Object.keys(tableState?.columns ?? {});
|
||||
const edited = !_isEqual(initialObject, extensionObject);
|
||||
|
||||
const handleChange = (_, newValue: number) => {
|
||||
setTabIndex(newValue);
|
||||
};
|
||||
|
||||
const handleAddOrUpdate = () => {
|
||||
switch (mode) {
|
||||
case "add":
|
||||
handleAdd(extensionObject);
|
||||
return;
|
||||
case "update":
|
||||
handleUpdate(extensionObject);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
title={
|
||||
<Button
|
||||
color="secondary"
|
||||
startIcon={<BackIcon />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
EXTENSIONS
|
||||
</Button>
|
||||
}
|
||||
children={
|
||||
<Box
|
||||
className={classes.modalRoot}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
className={classes.metaRoot}
|
||||
>
|
||||
<Grid item xs={4}>
|
||||
<TextField
|
||||
size="small"
|
||||
label={
|
||||
edited && !extensionObject.name.length
|
||||
? "Extension name (required)"
|
||||
: "Extension name"
|
||||
}
|
||||
variant="filled"
|
||||
fullWidth
|
||||
value={extensionObject.name}
|
||||
error={edited && !extensionObject.name.length}
|
||||
onChange={(event) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
name: event.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
className={classes.hoverable}
|
||||
onClick={() => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
active: !extensionObject.active,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Switch color="primary" checked={extensionObject.active} />
|
||||
<Typography>
|
||||
Extention is {!extensionObject.active && "de"}activated
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Tooltip title="Extension type cannot be changed once created.">
|
||||
<TextField
|
||||
size="small"
|
||||
label="Extension Type"
|
||||
value={extensionObject.type}
|
||||
variant="filled"
|
||||
fullWidth
|
||||
disabled
|
||||
/>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box
|
||||
className={classes.tabWrapper}
|
||||
flexGrow={1}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<AppBar position="static" className={classes.tabRoot} elevation={0}>
|
||||
<Tabs
|
||||
value={tabIndex}
|
||||
onChange={handleChange}
|
||||
variant="fullWidth"
|
||||
centered
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
>
|
||||
<Tab label="Triggers & Requirements" />
|
||||
<Tab label="Parameters" />
|
||||
</Tabs>
|
||||
</AppBar>
|
||||
<TabPanel value={tabIndex} index={0}>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
justify="center"
|
||||
alignItems="flex-start"
|
||||
>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2">
|
||||
Select a trigger that runs your extension code. Selected
|
||||
actions on any cells will trigger the extension.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="overline" className={classes.label}>
|
||||
Triggers
|
||||
</Typography>
|
||||
</Box>
|
||||
{triggerTypes.map((trigger) => (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
className={classes.hoverable}
|
||||
onClick={() => {
|
||||
if (extensionObject.triggers.includes(trigger)) {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
triggers: extensionObject.triggers.filter(
|
||||
(t) => t !== trigger
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
triggers: [...extensionObject.triggers, trigger],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={extensionObject.triggers.includes(trigger)}
|
||||
name={trigger}
|
||||
/>
|
||||
<Typography>{trigger}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
<Grid item xs={6} className={classes.requiredFields}>
|
||||
<Typography variant="body2">
|
||||
Optionally, select the fields that are required for the
|
||||
extension to be triggered for a row.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="overline" className={classes.label}>
|
||||
Required Fields (Optional)
|
||||
</Typography>
|
||||
</Box>
|
||||
{columns.sort().map((field) => (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
className={classes.hoverable}
|
||||
onClick={() => {
|
||||
if (extensionObject.requiredFields.includes(field)) {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields: extensionObject.requiredFields.filter(
|
||||
(t) => t !== field
|
||||
),
|
||||
});
|
||||
} else {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields: [
|
||||
...extensionObject.requiredFields,
|
||||
field,
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={extensionObject.requiredFields.includes(field)}
|
||||
name={field}
|
||||
/>
|
||||
<Typography>{field}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{extensionObject.requiredFields.map((trigger, index) => {
|
||||
const isFiretableColumn = columns.includes(trigger);
|
||||
if (isFiretableColumn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="center">
|
||||
<IconButton
|
||||
color="secondary"
|
||||
component="span"
|
||||
className={classes.removeField}
|
||||
onClick={() => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields: extensionObject.requiredFields.filter(
|
||||
(t) => t !== trigger
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<TextField
|
||||
label="Firestore field"
|
||||
variant="outlined"
|
||||
value={trigger}
|
||||
size="small"
|
||||
onChange={(event) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields: extensionObject.requiredFields.map(
|
||||
(value, i) =>
|
||||
i === index ? event.target.value : value
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="text"
|
||||
color="secondary"
|
||||
className={classes.addField}
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
requiredFields: [...extensionObject.requiredFields, ""],
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add a new Firestore field
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box className={classes.tabPanel} flexGrow={1}>
|
||||
<Typography variant="overline" className={classes.label}>
|
||||
Conditions
|
||||
</Typography>
|
||||
<CodeEditor
|
||||
script={extensionObject.conditions}
|
||||
height="100%"
|
||||
handleChange={(newValue) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
conditions: newValue,
|
||||
});
|
||||
}}
|
||||
onValideStatusUpdate={({ isValid }) => {
|
||||
if (!conditionEditorActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
setValidation({
|
||||
...validationRef.current,
|
||||
condition: isValid,
|
||||
});
|
||||
console.log(validationRef.current);
|
||||
}}
|
||||
diagnosticsOptions={{
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: true,
|
||||
}}
|
||||
onMount={() => {
|
||||
setConditionEditorActive(true);
|
||||
}}
|
||||
onUnmount={() => {
|
||||
setConditionEditorActive(false);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<CodeEditorHelper
|
||||
docLink="https://github.com/FiretableProject/firetable/wiki/Extensions"
|
||||
additionalVariables={additionalVariables}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value={tabIndex} index={1}>
|
||||
<Box className={classes.tabPanel} flexGrow={1}>
|
||||
<Typography variant="overline" className={classes.label}>
|
||||
Extension Body
|
||||
</Typography>
|
||||
<CodeEditor
|
||||
script={extensionObject.extensionBody}
|
||||
height="100%"
|
||||
handleChange={(newValue) => {
|
||||
setExtensionObject({
|
||||
...extensionObject,
|
||||
extensionBody: newValue,
|
||||
});
|
||||
}}
|
||||
onValideStatusUpdate={({ isValid }) => {
|
||||
if (!bodyEditorActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
setValidation({
|
||||
...validationRef.current,
|
||||
extensionBody: isValid,
|
||||
});
|
||||
console.log(validationRef.current);
|
||||
}}
|
||||
diagnosticsOptions={{
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: true,
|
||||
}}
|
||||
onMount={() => {
|
||||
setBodyEditorActive(true);
|
||||
}}
|
||||
onUnmount={() => {
|
||||
setBodyEditorActive(false);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<CodeEditorHelper
|
||||
docLink="https://github.com/FiretableProject/firetable/wiki/Extensions"
|
||||
additionalVariables={additionalVariables}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
actions={{
|
||||
primary: {
|
||||
children: mode === "add" ? "Add" : "Update",
|
||||
disabled: !edited || !extensionObject.name.length,
|
||||
onClick: () => {
|
||||
let warningMessage;
|
||||
if (!validation.condition && !validation.extensionBody) {
|
||||
warningMessage = "Condition and extention body are not valid";
|
||||
} else if (!validation.condition) {
|
||||
warningMessage = "Condition is not valid";
|
||||
} else if (!validation.extensionBody) {
|
||||
warningMessage = "Extention body is not valid";
|
||||
}
|
||||
|
||||
if (warningMessage) {
|
||||
requestConfirmation({
|
||||
title: "Validation failed",
|
||||
body: `${warningMessage}, do you want to continue?`,
|
||||
confirm: "Yes, I know what I am doing",
|
||||
cancel: "No, I'll fix the errors",
|
||||
handleConfirm: handleAddOrUpdate,
|
||||
});
|
||||
} else {
|
||||
handleAddOrUpdate();
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
290
www/src/components/Table/TableHeader/Extensions/index.tsx
Normal file
290
www/src/components/Table/TableHeader/Extensions/index.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useState } from "react";
|
||||
import _isEqual from "lodash/isEqual";
|
||||
import { useConfirmation } from "components/ConfirmationDialog";
|
||||
import { useSnackContext } from "contexts/SnackContext";
|
||||
import { db } from "../../../../firebase";
|
||||
|
||||
import { Breadcrumbs, Typography, Button } from "@material-ui/core";
|
||||
import TableHeaderButton from "../TableHeaderButton";
|
||||
import ExtensionIcon from "@material-ui/icons/ExtensionOutlined";
|
||||
import Modal from "components/Modal";
|
||||
import { useFiretableContext } from "contexts/FiretableContext";
|
||||
import { useAppContext } from "contexts/AppContext";
|
||||
import { useSnackLogContext } from "contexts/SnackLogContext";
|
||||
import ExtensionList from "./ExtensionList";
|
||||
import ExtensionModal from "./ExtensionModal";
|
||||
import ExtensionMigration from "./ExtensionMigration";
|
||||
|
||||
import {
|
||||
serialiseExtension,
|
||||
emptyExtensionObject,
|
||||
IExtension,
|
||||
IExtensionType,
|
||||
} from "./utils";
|
||||
import WIKI_LINKS from "constants/wikiLinks";
|
||||
|
||||
export default function ExtensionsEditor() {
|
||||
const snack = useSnackContext();
|
||||
const { tableState, tableActions } = useFiretableContext();
|
||||
const appContext = useAppContext();
|
||||
const { requestConfirmation } = useConfirmation();
|
||||
const currentextensionObjects = (tableState?.config.extensionObjects ??
|
||||
[]) as IExtension[];
|
||||
const [localExtensionsObjects, setLocalExtensionsObjects] = useState(
|
||||
currentextensionObjects
|
||||
);
|
||||
const [openExtensionList, setOpenExtensionList] = useState(false);
|
||||
const [openMigrationGuide, setOpenMigrationGuide] = useState(false);
|
||||
const [extensionModal, setExtensionModal] = useState<{
|
||||
mode: "add" | "update";
|
||||
extensionObject: IExtension;
|
||||
index?: number;
|
||||
} | null>(null);
|
||||
const snackLogContext = useSnackLogContext();
|
||||
const edited = !_isEqual(currentextensionObjects, localExtensionsObjects);
|
||||
|
||||
const tablePathTokens =
|
||||
tableState?.tablePath?.split("/").filter(function (_, i) {
|
||||
// replace IDs with dash that appears at even indexes
|
||||
return i % 2 === 0;
|
||||
}) ?? [];
|
||||
|
||||
const handleOpen = () => {
|
||||
if (tableState?.config.sparks) {
|
||||
// migration is required
|
||||
console.log("Extension migration required.");
|
||||
setOpenMigrationGuide(true);
|
||||
} else {
|
||||
setOpenExtensionList(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (edited) {
|
||||
requestConfirmation({
|
||||
title: "Discard Changes",
|
||||
body: "You will lose changes you have made to extensions",
|
||||
confirm: "Discard",
|
||||
handleConfirm: () => {
|
||||
setLocalExtensionsObjects(currentextensionObjects);
|
||||
setOpenExtensionList(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setOpenExtensionList(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveExtensions = () => {
|
||||
tableActions?.table.updateConfig(
|
||||
"extensionObjects",
|
||||
localExtensionsObjects
|
||||
);
|
||||
setOpenExtensionList(false);
|
||||
};
|
||||
|
||||
const handleSaveDeploy = async () => {
|
||||
handleSaveExtensions();
|
||||
|
||||
// compile extension objects into ft-build readable extension string
|
||||
const serialisedExtension = serialiseExtension(localExtensionsObjects);
|
||||
tableActions?.table.updateConfig("extensions", serialisedExtension);
|
||||
|
||||
const settingsDoc = await db.doc("/_FIRETABLE_/settings").get();
|
||||
const ftBuildUrl = settingsDoc.get("ftBuildUrl");
|
||||
if (!ftBuildUrl) {
|
||||
snack.open({
|
||||
message: "Cloud Run Trigger URL not set.",
|
||||
variant: "error",
|
||||
action: (
|
||||
<Button
|
||||
variant="contained"
|
||||
component="a"
|
||||
target="_blank"
|
||||
href={WIKI_LINKS.FtFunctions}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Setup Guide
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// request GCP build
|
||||
const userTokenInfo = await appContext?.currentUser?.getIdTokenResult();
|
||||
const userToken = userTokenInfo?.token;
|
||||
try {
|
||||
snackLogContext.requestSnackLog();
|
||||
const response = await fetch(ftBuildUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
configPath: tableState?.config.tableConfig.path,
|
||||
token: userToken,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddExtension = (extensionObject: IExtension) => {
|
||||
setLocalExtensionsObjects([...localExtensionsObjects, extensionObject]);
|
||||
setExtensionModal(null);
|
||||
};
|
||||
|
||||
const handleUpdateExtension = (extensionObject: IExtension) => {
|
||||
setLocalExtensionsObjects(
|
||||
localExtensionsObjects.map((extension, index) => {
|
||||
if (index === extensionModal?.index) {
|
||||
return {
|
||||
...extensionObject,
|
||||
lastEditor: currentEditor(),
|
||||
};
|
||||
} else {
|
||||
return extension;
|
||||
}
|
||||
})
|
||||
);
|
||||
setExtensionModal(null);
|
||||
};
|
||||
|
||||
const handleUpdateActive = (index: number, active: boolean) => {
|
||||
setLocalExtensionsObjects(
|
||||
localExtensionsObjects.map((extensionObject, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
...extensionObject,
|
||||
active,
|
||||
lastEditor: currentEditor(),
|
||||
};
|
||||
} else {
|
||||
return extensionObject;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicate = (index: number) => {
|
||||
setLocalExtensionsObjects([
|
||||
...localExtensionsObjects,
|
||||
{
|
||||
...localExtensionsObjects[index],
|
||||
name: `${localExtensionsObjects[index].name} (duplicate)`,
|
||||
active: false,
|
||||
lastEditor: currentEditor(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleEdit = (index: number) => {
|
||||
setExtensionModal({
|
||||
mode: "update",
|
||||
extensionObject: localExtensionsObjects[index],
|
||||
index,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
requestConfirmation({
|
||||
title: `Delete ${localExtensionsObjects[index].name}?`,
|
||||
body: "This extension will be permanently deleted.",
|
||||
confirm: "Confirm",
|
||||
handleConfirm: () => {
|
||||
setLocalExtensionsObjects(
|
||||
localExtensionsObjects.filter((_, i) => i !== index)
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const currentEditor = () => ({
|
||||
displayName: appContext?.currentUser?.displayName ?? "Unknown user",
|
||||
photoURL: appContext?.currentUser?.photoURL ?? "",
|
||||
lastUpdate: Date.now(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeaderButton
|
||||
title="Extensions"
|
||||
onClick={handleOpen}
|
||||
icon={<ExtensionIcon />}
|
||||
/>
|
||||
|
||||
{openExtensionList && !!tableState && (
|
||||
<Modal
|
||||
open={openExtensionList}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
title={<>Extensions</>}
|
||||
children={
|
||||
<>
|
||||
<Breadcrumbs aria-label="breadcrumb">
|
||||
{tablePathTokens.map((pathToken) => {
|
||||
return <Typography>{pathToken}</Typography>;
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
<ExtensionList
|
||||
extensions={localExtensionsObjects}
|
||||
handleAddExtension={(type: IExtensionType) => {
|
||||
setExtensionModal({
|
||||
mode: "add",
|
||||
extensionObject: emptyExtensionObject(
|
||||
type,
|
||||
currentEditor()
|
||||
),
|
||||
});
|
||||
}}
|
||||
handleUpdateActive={handleUpdateActive}
|
||||
handleEdit={handleEdit}
|
||||
handleDuplicate={handleDuplicate}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={{
|
||||
primary: {
|
||||
children: "Save & Deploy",
|
||||
onClick: handleSaveDeploy,
|
||||
disabled: !edited,
|
||||
},
|
||||
secondary: {
|
||||
children: "Save",
|
||||
onClick: handleSaveExtensions,
|
||||
disabled: !edited,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{extensionModal && (
|
||||
<ExtensionModal
|
||||
handleClose={() => {
|
||||
setExtensionModal(null);
|
||||
}}
|
||||
handleAdd={handleAddExtension}
|
||||
handleUpdate={handleUpdateExtension}
|
||||
mode={extensionModal.mode}
|
||||
extensionObject={extensionModal.extensionObject}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openMigrationGuide && (
|
||||
<ExtensionMigration
|
||||
handleClose={() => {
|
||||
setOpenMigrationGuide(false);
|
||||
}}
|
||||
handleUpgradeComplete={() => {
|
||||
setOpenMigrationGuide(false);
|
||||
setOpenExtensionList(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
278
www/src/components/Table/TableHeader/Extensions/utils.ts
Normal file
278
www/src/components/Table/TableHeader/Extensions/utils.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
type IExtensionType =
|
||||
| "task"
|
||||
| "docSync"
|
||||
| "historySnapshot"
|
||||
| "algoliaIndex"
|
||||
| "meiliIndex"
|
||||
| "bigqueryIndex"
|
||||
| "slackMessage"
|
||||
| "sendgridEmail"
|
||||
| "apiCall"
|
||||
| "twilioMessage";
|
||||
|
||||
type IExtensionTrigger = "create" | "update" | "delete";
|
||||
|
||||
interface IExtensionEditor {
|
||||
displayName: string;
|
||||
photoURL: string;
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
interface IExtension {
|
||||
// firetable meta fields
|
||||
name: string;
|
||||
active: boolean;
|
||||
lastEditor: IExtensionEditor;
|
||||
|
||||
// ft build fields
|
||||
triggers: IExtensionTrigger[];
|
||||
type: IExtensionType;
|
||||
requiredFields: string[];
|
||||
extensionBody: string;
|
||||
conditions: string;
|
||||
}
|
||||
|
||||
const triggerTypes: IExtensionTrigger[] = ["create", "update", "delete"];
|
||||
|
||||
const extensionTypes: IExtensionType[] = [
|
||||
"task",
|
||||
"docSync",
|
||||
"historySnapshot",
|
||||
"algoliaIndex",
|
||||
"meiliIndex",
|
||||
"bigqueryIndex",
|
||||
"slackMessage",
|
||||
"sendgridEmail",
|
||||
"apiCall",
|
||||
"twilioMessage",
|
||||
];
|
||||
|
||||
const extensionBodyTemplate = {
|
||||
task: `const extensionBody: TaskBody = async({row, db, change, ref}) => {
|
||||
// task extensions are very flexible you can do anything from updating other documents in your database, to making an api request to 3rd party service.
|
||||
|
||||
// eg:
|
||||
// const got = require('got');
|
||||
// const {body} = await got.post('https://httpbin.org/anything', {
|
||||
// json: {
|
||||
// hello: 'world'
|
||||
// },
|
||||
// responseType: 'json'
|
||||
// });
|
||||
|
||||
// console.log(body.data);
|
||||
// => {hello: 'world'}
|
||||
|
||||
console.log("Task Extension completed.")
|
||||
}`,
|
||||
docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return ({
|
||||
fieldsToSync: [], // a list of string of column names
|
||||
row: row, // object of data to sync, usually the row itself
|
||||
targetPath: "", // fill in the path here
|
||||
})
|
||||
}`,
|
||||
historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return ({
|
||||
trackedFields: [], // a list of string of column names
|
||||
})
|
||||
}`,
|
||||
algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return ({
|
||||
fieldsToSync: [], // a list of string of column names
|
||||
row: row, // object of data to sync, usually the row itself
|
||||
index: "", // algolia index to sync to
|
||||
objectID: ref.id, // algolia object ID, ref.id is one possible choice
|
||||
})
|
||||
}`,
|
||||
meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return({
|
||||
fieldsToSync: [], // a list of string of column names
|
||||
row: row, // object of data to sync, usually the row itself
|
||||
index: "", // algolia index to sync to
|
||||
objectID: ref.id, // algolia object ID, ref.id is one possible choice
|
||||
})
|
||||
}`,
|
||||
bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return ({
|
||||
fieldsToSync: [], // a list of string of column names
|
||||
row: row, // object of data to sync, usually the row itself
|
||||
index: "", // algolia index to sync to
|
||||
objectID: ref.id, // algolia object ID, ref.id is one possible choice
|
||||
})
|
||||
}`,
|
||||
slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return ({
|
||||
channels: [], // a list of slack channel IDs in string
|
||||
blocks: [], // the blocks parameter to pass in to slack api
|
||||
text: "", // the text parameter to pass in to slack api
|
||||
attachments: [], // the attachments parameter to pass in to slack api
|
||||
})
|
||||
}`,
|
||||
sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return ({
|
||||
from: "Name<example@domain.com>", // send from field
|
||||
personalizations: [
|
||||
{
|
||||
to: [{ name: "", email: "" }], // recipient
|
||||
dynamic_template_data: {
|
||||
}, // template parameters
|
||||
},
|
||||
],
|
||||
template_id: "", // sendgrid template ID
|
||||
categories: [], // helper info to categorise sendgrid emails
|
||||
})
|
||||
}`,
|
||||
apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return ({
|
||||
body: "",
|
||||
url: "",
|
||||
method: "",
|
||||
callback: ()=>{},
|
||||
})
|
||||
}`,
|
||||
twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref}) => {
|
||||
// feel free to add your own code logic here
|
||||
|
||||
return ({
|
||||
from:"",
|
||||
to:"",
|
||||
body:"Hi there!"
|
||||
})
|
||||
}`,
|
||||
};
|
||||
|
||||
function emptyExtensionObject(
|
||||
type: IExtensionType,
|
||||
user: IExtensionEditor
|
||||
): IExtension {
|
||||
return {
|
||||
name: "Untitled extension",
|
||||
active: false,
|
||||
triggers: [],
|
||||
type,
|
||||
extensionBody: extensionBodyTemplate[type] ?? extensionBodyTemplate["task"],
|
||||
requiredFields: [],
|
||||
conditions: `const condition: Condition = async({row, change}) => {
|
||||
// feel free to add your own code logic here
|
||||
return true;
|
||||
}`,
|
||||
lastEditor: user,
|
||||
};
|
||||
}
|
||||
|
||||
/* Convert extension objects into a single ft-build readable string */
|
||||
function serialiseExtension(extensions: IExtension[]): string {
|
||||
const serialisedExtension =
|
||||
"[" +
|
||||
extensions
|
||||
.filter((extension) => extension.active)
|
||||
.map(
|
||||
(extension) => `{
|
||||
name: "${extension.name}",
|
||||
type: "${extension.type}",
|
||||
triggers: [${extension.triggers
|
||||
.map((trigger) => `"${trigger}"`)
|
||||
.join(", ")}],
|
||||
conditions: ${extension.conditions
|
||||
.replace(/^.*:\s*Condition\s*=/, "")
|
||||
.replace(/\s*;\s*$/, "")},
|
||||
requiredFields: [${extension.requiredFields
|
||||
.map((field) => `"${field}"`)
|
||||
.join(", ")}],
|
||||
extensionBody: ${extension.extensionBody
|
||||
.replace(/^.*:\s*\w*Body\s*=/, "")
|
||||
.replace(/\s*;\s*$/, "")}
|
||||
}`
|
||||
)
|
||||
.join(",") +
|
||||
"]";
|
||||
console.log("serialisedExtension", serialisedExtension);
|
||||
return serialisedExtension;
|
||||
}
|
||||
|
||||
function sparkToExtensionObjects(
|
||||
sparkConfig: string,
|
||||
user: IExtensionEditor
|
||||
): IExtension[] {
|
||||
const parseString2Array = (str: string): string[] => {
|
||||
return str
|
||||
.trim()
|
||||
.replace(/\[|\]/g, "")
|
||||
.split(",")
|
||||
.map((x) => x.trim().replace(/'/g, ""));
|
||||
};
|
||||
const oldSparks = sparkConfig.replace(/"/g, "'");
|
||||
const sparkTypes = oldSparks
|
||||
.match(/(?<=type:).*(?=,)/g)
|
||||
?.map((x) => x.trim().replace(/'/g, ""));
|
||||
const triggers = oldSparks
|
||||
.match(/(?<=triggers:).*(?=,)/g)
|
||||
?.map((x) => parseString2Array(x));
|
||||
const shouldRun = oldSparks
|
||||
.match(/(?<=shouldRun:).*(?=,)/g)
|
||||
?.map((x) => x.trim());
|
||||
const requiredFields = oldSparks
|
||||
.match(/(?<=requiredFields:).*(?=,)/g)
|
||||
?.map((x) => parseString2Array(x));
|
||||
const splitSparks = oldSparks.split(`type:`);
|
||||
const sparks = sparkTypes?.map((x, index) => {
|
||||
const sparkBody = splitSparks[index + 1]
|
||||
?.split("sparkBody:")[1]
|
||||
?.trim()
|
||||
.slice(0, -1);
|
||||
const _triggers = triggers?.[index];
|
||||
const _shouldRun = shouldRun?.[index];
|
||||
const _requiredFields = requiredFields?.[index];
|
||||
return {
|
||||
type: x,
|
||||
triggers: _triggers,
|
||||
shouldRun: _shouldRun,
|
||||
requiredFields: _requiredFields,
|
||||
sparkBody,
|
||||
};
|
||||
});
|
||||
const extensionObjects = sparks?.map(
|
||||
(spark, index): IExtension => {
|
||||
return {
|
||||
// firetable meta fields
|
||||
name: `Migrated spark ${index}`,
|
||||
active: true,
|
||||
lastEditor: user,
|
||||
|
||||
// ft build fields
|
||||
triggers: (spark.triggers ?? []) as IExtensionTrigger[],
|
||||
type: spark.type as IExtensionType,
|
||||
requiredFields: spark.requiredFields ?? [],
|
||||
extensionBody: spark.sparkBody,
|
||||
conditions: spark.shouldRun ?? "",
|
||||
};
|
||||
}
|
||||
);
|
||||
return extensionObjects ?? [];
|
||||
}
|
||||
|
||||
export {
|
||||
serialiseExtension,
|
||||
extensionTypes,
|
||||
triggerTypes,
|
||||
emptyExtensionObject,
|
||||
sparkToExtensionObjects,
|
||||
};
|
||||
export type { IExtension, IExtensionType, IExtensionEditor };
|
||||
@@ -73,8 +73,8 @@ export default function ReExecute() {
|
||||
header={
|
||||
<>
|
||||
<DialogContentText>
|
||||
Are you sure you want to force a re-execute of all Sparks and
|
||||
Derivatives?
|
||||
Are you sure you want to force a re-execute of all Extensions
|
||||
and Derivatives?
|
||||
</DialogContentText>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useConfirmation } from "components/ConfirmationDialog";
|
||||
import { useSnackContext } from "contexts/SnackContext";
|
||||
import { db } from "../../../firebase";
|
||||
|
||||
import { DialogContentText, Chip, Stack } from "@material-ui/core";
|
||||
import Alert from "@material-ui/core/Alert";
|
||||
import TableHeaderButton from "./TableHeaderButton";
|
||||
import SparkIcon from "@material-ui/icons/OfflineBoltOutlined";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import Modal from "components/Modal";
|
||||
import { useFiretableContext } from "contexts/FiretableContext";
|
||||
import { useAppContext } from "contexts/AppContext";
|
||||
import { useSnackLogContext } from "contexts/SnackLogContext";
|
||||
import CodeEditor from "../editors/CodeEditor";
|
||||
|
||||
import routes from "constants/routes";
|
||||
export default function SparksEditor() {
|
||||
const snack = useSnackContext();
|
||||
const { tableState, tableActions } = useFiretableContext();
|
||||
const appContext = useAppContext();
|
||||
const { requestConfirmation } = useConfirmation();
|
||||
const currentSparks = tableState?.config.sparks ?? "";
|
||||
const [localSparks, setLocalSparks] = useState(currentSparks);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isSparksValid, setIsSparksValid] = useState(true);
|
||||
const [showForceSave, setShowForceSave] = useState(false);
|
||||
const snackLogContext = useSnackLogContext();
|
||||
|
||||
const handleClose = () => {
|
||||
if (currentSparks !== localSparks) {
|
||||
requestConfirmation({
|
||||
title: "Discard Changes",
|
||||
body: "You will lose changes you have made to this spark",
|
||||
confirm: "Discard",
|
||||
handleConfirm: () => {
|
||||
setOpen(false);
|
||||
setLocalSparks(currentSparks);
|
||||
},
|
||||
});
|
||||
} else setOpen(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
tableActions?.table.updateConfig("sparks", localSparks);
|
||||
setOpen(false);
|
||||
requestConfirmation({
|
||||
title: "Deploy Changes",
|
||||
body: "Would you like to redeploy the cloud function for this table now?",
|
||||
confirm: "Deploy",
|
||||
cancel: "later",
|
||||
handleConfirm: async () => {
|
||||
const settingsDoc = await db.doc("/_FIRETABLE_/settings").get();
|
||||
const ftBuildUrl = settingsDoc.get("ftBuildUrl");
|
||||
if (!ftBuildUrl) {
|
||||
snack.open({
|
||||
message: `Firetable functions builder is not yet setup`,
|
||||
variant: "error",
|
||||
action: (
|
||||
<Button
|
||||
variant="contained"
|
||||
component={"a"}
|
||||
target="_blank"
|
||||
href={routes.projectSettings}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Go to Settings
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const userTokenInfo = await appContext?.currentUser?.getIdTokenResult();
|
||||
const userToken = userTokenInfo?.token;
|
||||
try {
|
||||
snackLogContext.requestSnackLog();
|
||||
const response = await fetch(ftBuildUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
configPath: tableState?.config.tableConfig.path,
|
||||
token: userToken,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyChange = (key) => {
|
||||
setShowForceSave(key.shiftKey && key.ctrlKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeaderButton
|
||||
title="Edit Sparks (ALPHA)"
|
||||
onClick={() => setOpen(true)}
|
||||
icon={<SparkIcon />}
|
||||
/>
|
||||
|
||||
{open && !!tableState && (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
disableBackdropClick
|
||||
disableEscapeKeyDown
|
||||
maxWidth="xl"
|
||||
fullWidth
|
||||
title={
|
||||
<>
|
||||
Edit “
|
||||
{tableState?.tablePath
|
||||
?.split("/")
|
||||
.filter(function (_, i) {
|
||||
// replace IDs with dash that appears at even indexes
|
||||
return i % 2 === 0;
|
||||
})
|
||||
.join("-")}
|
||||
” Sparks <Chip label="ALPHA" size="small" />
|
||||
</>
|
||||
}
|
||||
children={
|
||||
<Stack style={{ height: "calc(100vh - 228px)" }}>
|
||||
<Alert severity="warning">
|
||||
This is an alpha feature. Cloud Functions and Google Cloud
|
||||
integration setup is required, but the process is not yet
|
||||
finalised.
|
||||
</Alert>
|
||||
|
||||
<DialogContentText>
|
||||
Write your Sparks configuration below. Each Spark will be
|
||||
evaluated and executed by Cloud Firestore <code>onWrite</code>{" "}
|
||||
triggers on rows in this table.
|
||||
</DialogContentText>
|
||||
|
||||
<div style={{ flexGrow: 1, flexBasis: 240 }}>
|
||||
<CodeEditor
|
||||
script={currentSparks}
|
||||
height="100%"
|
||||
handleChange={(newValue) => {
|
||||
setLocalSparks(newValue);
|
||||
}}
|
||||
onValideStatusUpdate={({ isValid }) => {
|
||||
setIsSparksValid(isValid);
|
||||
}}
|
||||
diagnosticsOptions={{
|
||||
noSemanticValidation: false,
|
||||
noSyntaxValidation: false,
|
||||
noSuggestionDiagnostics: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isSparksValid && (
|
||||
<Alert severity="error">
|
||||
You need to resolve all errors before you are able to save. Or
|
||||
press shift and control key to enable force save.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
actions={{
|
||||
primary: showForceSave
|
||||
? {
|
||||
children: "Force Save",
|
||||
onClick: handleSave,
|
||||
}
|
||||
: {
|
||||
children: "Save Changes",
|
||||
onClick: handleSave,
|
||||
disabled:
|
||||
!isSparksValid || localSparks === tableState?.config.sparks,
|
||||
},
|
||||
secondary: {
|
||||
children: "Cancel",
|
||||
onClick: handleClose,
|
||||
},
|
||||
}}
|
||||
onKeyDown={handleKeyChange}
|
||||
onKeyUp={handleKeyChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import TableSettings from "./TableSettings";
|
||||
import TableLogs from "./TableLogs";
|
||||
import HiddenFields from "../HiddenFields";
|
||||
import RowHeight from "./RowHeight";
|
||||
import Sparks from "./Sparks";
|
||||
import Extensions from "./Extensions";
|
||||
import ReExecute from "./ReExecute";
|
||||
|
||||
import { useAppContext } from "contexts/AppContext";
|
||||
@@ -66,8 +66,9 @@ export default function TableHeader() {
|
||||
Object.values(tableState.columns)?.filter(
|
||||
(column) => column.type === FieldType.derivative
|
||||
).length > 0;
|
||||
const hasSparks =
|
||||
tableState && tableState.config?.sparks?.replace(/\W/g, "")?.length > 0;
|
||||
const hasExtensions =
|
||||
tableState &&
|
||||
tableState.config?.compiledExtension?.replace(/\W/g, "")?.length > 0;
|
||||
|
||||
if (!tableState || !tableState.columns) return null;
|
||||
const { columns } = tableState;
|
||||
@@ -171,7 +172,7 @@ export default function TableHeader() {
|
||||
|
||||
{userClaims?.roles?.includes("ADMIN") && (
|
||||
<Grid item>
|
||||
<Sparks />
|
||||
<Extensions />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
@@ -181,11 +182,12 @@ export default function TableHeader() {
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{userClaims?.roles?.includes("ADMIN") && (hasDerivatives || hasSparks) && (
|
||||
<Grid item>
|
||||
<ReExecute />
|
||||
</Grid>
|
||||
)}
|
||||
{userClaims?.roles?.includes("ADMIN") &&
|
||||
(hasDerivatives || hasExtensions) && (
|
||||
<Grid item>
|
||||
<ReExecute />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid item>
|
||||
<TableSettings />
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useTheme } from "@material-ui/core/styles";
|
||||
import Editor, { useMonaco } from "@monaco-editor/react";
|
||||
import { useFiretableContext } from "contexts/FiretableContext";
|
||||
import { FieldType } from "constants/fields";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const useStyles = makeStyles((theme) =>
|
||||
createStyles({
|
||||
editorWrapper: {
|
||||
position: "relative",
|
||||
minWidth: 800,
|
||||
height: "100%",
|
||||
minWidth: 400,
|
||||
minHeight: 100,
|
||||
height: "calc(100% - 50px)",
|
||||
},
|
||||
resizeIcon: {
|
||||
position: "absolute",
|
||||
@@ -37,6 +39,8 @@ export default function CodeEditor(props: any) {
|
||||
script,
|
||||
onValideStatusUpdate,
|
||||
diagnosticsOptions,
|
||||
onUnmount,
|
||||
onMount,
|
||||
} = props;
|
||||
const theme = useTheme();
|
||||
const monacoInstance = useMonaco();
|
||||
@@ -47,8 +51,15 @@ export default function CodeEditor(props: any) {
|
||||
|
||||
const editorRef = useRef<any>();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onUnmount?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleEditorDidMount(_, editor) {
|
||||
editorRef.current = editor;
|
||||
onMount?.();
|
||||
}
|
||||
|
||||
const themeTransformer = (theme: string) => {
|
||||
@@ -199,17 +210,19 @@ export default function CodeEditor(props: any) {
|
||||
.map((columnKey: string) => `"${columnKey}"`)
|
||||
.join("|\n");
|
||||
|
||||
const sparksDefinition = `declare namespace sparks {
|
||||
|
||||
const extensionsDefinition = `
|
||||
// basic types that are used in all places
|
||||
type Row = {${rowDefinition}};
|
||||
type Field = ${availableFields} | string | object;
|
||||
type Fields = Field[];
|
||||
type Trigger = "create" | "update" | "delete";
|
||||
type Triggers = Trigger[];
|
||||
|
||||
// function types that defines extension body and shuold run
|
||||
type Condition = boolean | ((data: ExtensionContext) => boolean | Promise<boolean>);
|
||||
|
||||
// the argument that the spark body takes in
|
||||
type SparkContext = {
|
||||
// the argument that the extension body takes in
|
||||
type ExtensionContext = {
|
||||
row: Row;
|
||||
ref:FirebaseFirestore.DocumentReference;
|
||||
storage:firebasestorage.Storage;
|
||||
@@ -217,199 +230,94 @@ export default function CodeEditor(props: any) {
|
||||
auth:adminauth.BaseAuth;
|
||||
change: any;
|
||||
triggerType: Triggers;
|
||||
sparkConfig: any;
|
||||
fieldTypes: any;
|
||||
extensionConfig: {
|
||||
label: string;
|
||||
type: sring;
|
||||
triggers: Trigger[];
|
||||
conditions: Condition;
|
||||
requiredFields: string[];
|
||||
extensionBody: any;
|
||||
};
|
||||
utilFns: any;
|
||||
}
|
||||
|
||||
// function types that defines spark body and shuold run
|
||||
type ShouldRun = boolean | ((data: SparkContext) => boolean | Promise<any>);
|
||||
type ContextToString = ((data: SparkContext) => string | Promise<any>);
|
||||
type ContextToStringList = ((data: SparkContext) => string[] | Promise<any>);
|
||||
type ContextToObject = ((data: SparkContext) => object | Promise<any>);
|
||||
type ContextToObjectList = ((data: SparkContext) => object[] | Promise<any>);
|
||||
type ContextToRow = ((data: SparkContext) => Row | Promise<any>);
|
||||
type ContextToAny = ((data: SparkContext) => any | Promise<any>);
|
||||
|
||||
// different types of bodies that slack message can use
|
||||
// extension body definition
|
||||
type slackEmailBody = {
|
||||
channels?: ContextToStringList;
|
||||
text?: ContextToString;
|
||||
emails: ContextToStringList;
|
||||
blocks?: ContextToObjectList;
|
||||
attachments?: ContextToAny;
|
||||
channels?: string[];
|
||||
text?: string;
|
||||
emails: string[];
|
||||
blocks?: object[];
|
||||
attachments?: any;
|
||||
}
|
||||
|
||||
type slackChannelBody = {
|
||||
channels: ContextToStringList;
|
||||
text?: ContextToString;
|
||||
emails?: ContextToStringList;
|
||||
blocks?: ContextToObjectList;
|
||||
attachments?: ContextToAny;
|
||||
}
|
||||
|
||||
// different types of sparks
|
||||
type docSync = {
|
||||
label?:string;
|
||||
type: "docSync";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
fieldsToSync: Fields;
|
||||
row: ContextToRow;
|
||||
targetPath: ContextToString;
|
||||
}
|
||||
};
|
||||
|
||||
type historySnapshot = {
|
||||
label?:string;
|
||||
type: "historySnapshot";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
sparkBody: {
|
||||
trackedFields: Fields;
|
||||
}
|
||||
}
|
||||
|
||||
type algoliaIndex = {
|
||||
label?:string;
|
||||
type: "algoliaIndex";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
fieldsToSync: Fields;
|
||||
index: string;
|
||||
row: ContextToRow;
|
||||
objectID: ContextToString;
|
||||
}
|
||||
}
|
||||
|
||||
type meiliIndex = {
|
||||
type: "meiliIndex";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
fieldsToSync: Fields;
|
||||
index: string;
|
||||
row: ContextToRow;
|
||||
objectID: ContextToString;
|
||||
}
|
||||
}
|
||||
|
||||
type bigqueryIndex = {
|
||||
type: "bigqueryIndex";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
fieldsToSync: Fields;
|
||||
index: string;
|
||||
row: ContextToRow;
|
||||
objectID: ContextToString;
|
||||
}
|
||||
channels: string[];
|
||||
text?: string;
|
||||
emails?: string[];
|
||||
blocks?: object[];
|
||||
attachments?: any;
|
||||
}
|
||||
|
||||
type slackMessage = {
|
||||
label?:string;
|
||||
type: "slackMessage";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: slackEmailBody | slackChannelBody;
|
||||
}
|
||||
|
||||
type sendgridEmail = {
|
||||
label?:string;
|
||||
type: "sendgridEmail";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
msg: ContextToAny;
|
||||
}
|
||||
}
|
||||
|
||||
type apiCall = {
|
||||
label?:string;
|
||||
type: "apiCall";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
body: ContextToString;
|
||||
url: ContextToString;
|
||||
method: ContextToString;
|
||||
callback: ContextToAny;
|
||||
}
|
||||
}
|
||||
|
||||
type twilioMessage = {
|
||||
label?:string;
|
||||
type: "twilioMessage";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
body: ContextToAny;
|
||||
from: ContextToAny;
|
||||
to: ContextToAny;
|
||||
}
|
||||
}
|
||||
type DocSyncBody = (context: ExtensionContext) => Promise<{
|
||||
fieldsToSync: Fields;
|
||||
row: Row;
|
||||
targetPath: string;
|
||||
}>
|
||||
|
||||
type task = {
|
||||
label?:string;
|
||||
type: "task";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
promises: ContextToAny;
|
||||
}
|
||||
}
|
||||
type HistorySnapshotBody = (context: ExtensionContext) => Promise<{
|
||||
trackedFields: Fields;
|
||||
}>
|
||||
|
||||
type mailchimp = {
|
||||
label?:string;
|
||||
type: "mailchimp";
|
||||
triggers: Triggers;
|
||||
shouldRun: ShouldRun;
|
||||
requiredFields?: Fields;
|
||||
sparkBody: {
|
||||
method: any;
|
||||
path: any;
|
||||
body: any;
|
||||
}
|
||||
}
|
||||
|
||||
// an individual spark
|
||||
type Spark =
|
||||
| docSync
|
||||
| historySnapshot
|
||||
| algoliaIndex
|
||||
| meiliIndex
|
||||
| bigqueryIndex
|
||||
| slackMessage
|
||||
| sendgridEmail
|
||||
| apiCall
|
||||
| twilioMessage
|
||||
| mailchimp
|
||||
| task;
|
||||
|
||||
type Sparks = Spark[]
|
||||
|
||||
// use spark.config(sparks) in the code editor for static type check
|
||||
function config(sparks: Sparks): void;
|
||||
}`;
|
||||
type AlgoliaIndexBody = (context: ExtensionContext) => Promise<{
|
||||
fieldsToSync: Fields;
|
||||
index: string;
|
||||
row: Row;
|
||||
objectID: string;
|
||||
}>
|
||||
|
||||
type MeiliIndexBody = (context: ExtensionContext) => Promise<{
|
||||
fieldsToSync: Fields;
|
||||
index: string;
|
||||
row: Row;
|
||||
objectID: string;
|
||||
}>
|
||||
|
||||
type BigqueryIndexBody = (context: ExtensionContext) => Promise<{
|
||||
fieldsToSync: Fields;
|
||||
index: string;
|
||||
row: Row;
|
||||
objectID: string;
|
||||
}>
|
||||
|
||||
type SlackMessageBody = (context: ExtensionContext) => Promise<slackEmailBody | slackChannelBody>;
|
||||
|
||||
type SendgridEmailBody = (context: ExtensionContext) => Promise<any>;
|
||||
|
||||
type ApiCallBody = (context: ExtensionContext) => Promise<{
|
||||
body: string;
|
||||
url: string;
|
||||
method: string;
|
||||
callback: any;
|
||||
}>
|
||||
|
||||
type TwilioMessageBody = (context: ExtensionContext) => Promise<{
|
||||
body: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}>
|
||||
|
||||
type TaskBody = (context: ExtensionContext) => Promise<any>
|
||||
`;
|
||||
|
||||
monacoInstance.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
[
|
||||
" /**",
|
||||
" * sparks type configuration",
|
||||
" * extensions type configuration",
|
||||
" */",
|
||||
sparksDefinition,
|
||||
extensionsDefinition,
|
||||
].join("\n"),
|
||||
"ts:filename/sparks.d.ts"
|
||||
"ts:filename/extensions.d.ts"
|
||||
);
|
||||
|
||||
monacoInstance.languages.typescript.javascriptDefaults.addExtraLib(
|
||||
|
||||
@@ -28,6 +28,8 @@ export type FiretableState = {
|
||||
tableConfig: any;
|
||||
webhooks: any;
|
||||
sparks: string;
|
||||
compiledExtension: string;
|
||||
extensionObjects?: any[];
|
||||
};
|
||||
columns: any[];
|
||||
rows: { [key: string]: any }[];
|
||||
@@ -76,6 +78,8 @@ const useFiretable = (
|
||||
rowHeight: tableConfig.rowHeight,
|
||||
webhooks: tableConfig.doc?.webhooks,
|
||||
sparks: tableConfig.doc?.sparks,
|
||||
compiledExtension: tableConfig.doc?.compiledExtension,
|
||||
extensionObjects: tableConfig.doc?.extensionObjects,
|
||||
tableConfig,
|
||||
},
|
||||
rows: tableState.rows,
|
||||
|
||||
Reference in New Issue
Block a user