diff --git a/package.json b/package.json index 6e2d86ea..43a94f4c 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "react-router-hash-link": "^2.4.3", "react-scripts": "^4.0.3", "react-usestateref": "^1.0.5", + "semver": "^7.3.5", "serve": "^11.3.2", "swr": "^1.0.1", "tinymce": "^5.9.2", diff --git a/src/assets/icons/Webhook.tsx b/src/assets/icons/Webhook.tsx new file mode 100644 index 00000000..3e0a79a0 --- /dev/null +++ b/src/assets/icons/Webhook.tsx @@ -0,0 +1,10 @@ +import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; +import { mdiWebhook } from "@mdi/js"; + +export default function Webhook(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/components/CodeEditor/useMonacoCustomizations.ts b/src/components/CodeEditor/useMonacoCustomizations.ts index 58d71bff..5364adf1 100644 --- a/src/components/CodeEditor/useMonacoCustomizations.ts +++ b/src/components/CodeEditor/useMonacoCustomizations.ts @@ -115,7 +115,10 @@ export default function useMonacoCustomizations({ Object.keys(tableState?.columns!) .map((columnKey: string) => { const column = tableState?.columns[columnKey]; - return `static ${columnKey}: ${getFieldProp("type", column.type)}`; + return `static "${columnKey}": ${getFieldProp( + "dataType", + column.type + )}`; }) .join(";\n") + ";"; diff --git a/src/components/Setup/Step2ServiceAccount.tsx b/src/components/Setup/Step2ServiceAccount.tsx index 3c0fb364..42d5adfe 100644 --- a/src/components/Setup/Step2ServiceAccount.tsx +++ b/src/components/Setup/Step2ServiceAccount.tsx @@ -20,7 +20,7 @@ export default function Step2ServiceAccount({ setCompletion, }: ISetupStepBodyProps) { const [hasAllRoles, setHasAllRoles] = useState(completion.serviceAccount); - const [roles, setRoles] = useState>({}); + // const [roles, setRoles] = useState>({}); const [verificationStatus, setVerificationStatus] = useState< "IDLE" | "LOADING" | "FAIL" >("IDLE"); @@ -40,7 +40,7 @@ export default function Step2ServiceAccount({ setVerificationStatus("LOADING"); try { const result = await checkServiceAccount(rowyRunUrl); - setRoles(result); + // setRoles(result); if (result.hasAllRoles) { setVerificationStatus("IDLE"); setHasAllRoles(true); diff --git a/src/components/Setup/Step4Rules.tsx b/src/components/Setup/Step4Rules.tsx index b2ae87f2..25bf020e 100644 --- a/src/components/Setup/Step4Rules.tsx +++ b/src/components/Setup/Step4Rules.tsx @@ -110,7 +110,7 @@ export default function Step4Rules({ setCompletion((c) => ({ ...c, rules: true })); setHasRules(true); } - setRulesStatus("IDLE"); + setRulesStatus(""); } catch (e: any) { console.error(e); setRulesStatus(e.message); diff --git a/src/components/Table/TableHeader/Extensions/Step4Body.tsx b/src/components/Table/TableHeader/Extensions/Step4Body.tsx index bcc27c19..bcb88357 100644 --- a/src/components/Table/TableHeader/Extensions/Step4Body.tsx +++ b/src/components/Table/TableHeader/Extensions/Step4Body.tsx @@ -61,11 +61,11 @@ export default function Step4Body({ extensionBody: isValid, }); }} - diagnosticsOptions={{ - noSemanticValidation: false, - noSyntaxValidation: false, - noSuggestionDiagnostics: true, - }} + // diagnosticsOptions={{ + // noSemanticValidation: false, + // noSyntaxValidation: false, + // noSuggestionDiagnostics: true, + // }} onMount={() => setBodyEditorActive(true)} onUnmount={() => setBodyEditorActive(false)} /> diff --git a/src/components/Table/TableHeader/Extensions/utils.ts b/src/components/Table/TableHeader/Extensions/utils.ts index 61366df4..3b657400 100644 --- a/src/components/Table/TableHeader/Extensions/utils.ts +++ b/src/components/Table/TableHeader/Extensions/utils.ts @@ -40,7 +40,7 @@ export interface IExtension { active: boolean; lastEditor: IExtensionEditor; - // ft build fields + // build fields triggers: ExtensionTrigger[]; type: ExtensionType; requiredFields: string[]; diff --git a/src/components/Table/TableHeader/Webhooks/Step1Secret.tsx b/src/components/Table/TableHeader/Webhooks/Step1Secret.tsx new file mode 100644 index 00000000..8459c03a --- /dev/null +++ b/src/components/Table/TableHeader/Webhooks/Step1Secret.tsx @@ -0,0 +1,23 @@ +import { IWebhookModalStepProps } from "./WebhookModal"; +import { useProjectContext } from "@src/contexts/ProjectContext"; +import { FormControl, FormLabel, TextField, Typography } from "@mui/material"; + +export default function Step1Endpoint({ + webhookObject, + setWebhookObject, +}: IWebhookModalStepProps) { + return ( + + + Secret + + + setWebhookObject({ ...webhookObject, secret: e.target.value }) + } + /> + + ); +} diff --git a/src/components/Table/TableHeader/Webhooks/Step2Conditions.tsx b/src/components/Table/TableHeader/Webhooks/Step2Conditions.tsx new file mode 100644 index 00000000..09903405 --- /dev/null +++ b/src/components/Table/TableHeader/Webhooks/Step2Conditions.tsx @@ -0,0 +1,57 @@ +import { IWebhookModalStepProps } from "./WebhookModal"; +import useStateRef from "react-usestateref"; + +import CodeEditor from "@src/components/CodeEditor"; +import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper"; + +import { WIKI_LINKS } from "@src/constants/externalLinks"; + +const additionalVariables = [ + { + key: "req", + description: "webhook request", + }, +]; + +export default function Step3Conditions({ + webhookObject, + setWebhookObject, + setValidation, + validationRef, +}: IWebhookModalStepProps) { + const [, setConditionEditorActive, conditionEditorActiveRef] = + useStateRef(false); + + return ( + <> +
+ { + setWebhookObject({ + ...webhookObject, + conditions: newValue || "", + }); + }} + onValidStatusUpdate={({ isValid }) => { + if (!conditionEditorActiveRef.current) return; + setValidation({ ...validationRef.current!, condition: isValid }); + }} + diagnosticsOptions={{ + noSemanticValidation: false, + noSyntaxValidation: false, + noSuggestionDiagnostics: true, + }} + onMount={() => setConditionEditorActive(true)} + onUnmount={() => setConditionEditorActive(false)} + /> +
+ + + + ); +} diff --git a/src/components/Table/TableHeader/Webhooks/Step3Parser.tsx b/src/components/Table/TableHeader/Webhooks/Step3Parser.tsx new file mode 100644 index 00000000..73526035 --- /dev/null +++ b/src/components/Table/TableHeader/Webhooks/Step3Parser.tsx @@ -0,0 +1,63 @@ +import { IWebhookModalStepProps } from "./WebhookModal"; +import _upperFirst from "lodash/upperFirst"; +import useStateRef from "react-usestateref"; + +import CodeEditor from "@src/components/CodeEditor"; +import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper"; + +import { WIKI_LINKS } from "@src/constants/externalLinks"; + +const additionalVariables = [ + { + key: "req", + description: "webhook request", + }, +]; + +export default function Step4Body({ + webhookObject, + setWebhookObject, + setValidation, + validationRef, +}: IWebhookModalStepProps) { + const [, setBodyEditorActive, bodyEditorActiveRef] = useStateRef(false); + + return ( + <> +
+ { + setWebhookObject({ + ...webhookObject, + parser: newValue || "", + }); + }} + onValidStatusUpdate={({ isValid }) => { + if (!bodyEditorActiveRef.current) return; + setValidation({ + ...validationRef.current!, + parser: isValid, + }); + }} + diagnosticsOptions={{ + noSemanticValidation: false, + noSyntaxValidation: false, + noSuggestionDiagnostics: true, + }} + onMount={() => setBodyEditorActive(true)} + onUnmount={() => setBodyEditorActive(false)} + /> +
+ + + + ); +} diff --git a/src/components/Table/TableHeader/Webhooks/WebhookList.tsx b/src/components/Table/TableHeader/Webhooks/WebhookList.tsx new file mode 100644 index 00000000..761f6d13 --- /dev/null +++ b/src/components/Table/TableHeader/Webhooks/WebhookList.tsx @@ -0,0 +1,261 @@ +import { useState, useRef } from "react"; +import { format, formatRelative } from "date-fns"; +import CopyIcon from "@src/assets/icons/Copy"; + +import { + Stack, + ButtonBase, + List, + ListItem, + ListItemText, + Avatar, + Button, + IconButton, + Menu, + MenuItem, + Switch, + Tooltip, + Typography, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import WebhookIcon from "@src/assets/icons/Webhook"; +import LogsIcon from "@src/assets/icons/CloudLogs"; +import EditIcon from "@mui/icons-material/EditOutlined"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; + +import EmptyState from "@src/components/EmptyState"; +import { webhookTypes, webhookNames, IWebhook, WebhookType } from "./utils"; +import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { useProjectContext } from "@src/contexts/ProjectContext"; + +export interface IWebhookListProps { + webhooks: IWebhook[]; + handleAddWebhook: (type: WebhookType) => void; + handleUpdateActive: (index: number, active: boolean) => void; + handleOpenLogs: (index: number) => void; + handleEdit: (index: number) => void; + handleDelete: (index: number) => void; +} + +export default function WebhookList({ + webhooks, + handleAddWebhook, + handleUpdateActive, + handleOpenLogs, + handleEdit, + handleDelete, +}: IWebhookListProps) { + const { settings, tableState } = useProjectContext(); + const [anchorEl, setAnchorEl] = useState(null); + const addButtonRef = useRef(null); + + const activeWebhookCount = webhooks.filter( + (webhook) => webhook.active + ).length; + + const handleAddButton = () => { + setAnchorEl(addButtonRef.current); + }; + + const handleChooseAddType = (type: WebhookType) => { + handleClose(); + handleAddWebhook(type); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const baseUrl = `${settings?.rowyRunUrl}/whs/${tableState?.tablePath}/`; + return ( + <> + + + Webhooks ({activeWebhookCount} / {webhooks.length}) + + + + + {webhookTypes.map((type) => ( + handleChooseAddType(type)}> + {webhookNames[type]} + + ))} + + + + {webhooks.length === 0 ? ( + + + + ) : ( + + {webhooks.map((webhook, index) => ( + + {webhook.name} {webhookNames[webhook.type]} + + } + secondary={ +
+
+ + + {baseUrl} + {webhook.endpoint} + + +
+ + + navigator.clipboard.writeText( + `${baseUrl}${webhook.endpoint}` + ) + } + > + + + +
+ } + //secondary={webhookNames[webhook.type]} + primaryTypographyProps={{ + style: { + minHeight: 40, + display: "flex", + alignItems: "center", + }, + }} + /> + } + secondaryAction={ + + + + + handleUpdateActive(index, !webhook.active) + } + inputProps={{ "aria-label": "Activate" }} + sx={{ mr: 1 }} + /> + + + + handleOpenLogs(index)} + > + + + + + handleEdit(index)} + > + + + + + handleDelete(index)} + sx={{ "&&": { mr: -1.5 } }} + > + + + + + + + Last updated +
+ by {webhook.lastEditor.displayName} +
+ at{" "} + {format( + webhook.lastEditor.lastUpdate, + DATE_TIME_FORMAT + )} + + } + > + + + {formatRelative( + webhook.lastEditor.lastUpdate, + new Date() + )} + + + +
+
+ } + /> + ))} +
+ )} + + ); +} diff --git a/src/components/Table/TableHeader/Webhooks/WebhookLogs.tsx b/src/components/Table/TableHeader/Webhooks/WebhookLogs.tsx new file mode 100644 index 00000000..a7bf1c1c --- /dev/null +++ b/src/components/Table/TableHeader/Webhooks/WebhookLogs.tsx @@ -0,0 +1,74 @@ +import _isEqual from "lodash/isEqual"; +import _upperFirst from "lodash/upperFirst"; + +import Modal, { IModalProps } from "@src/components/Modal"; + +import { IWebhook } from "./utils"; +import useCollection from "@src/hooks/useCollection"; +import { useEffect } from "react"; +import { useProjectContext } from "@src/contexts/ProjectContext"; +import { Typography } from "@mui/material"; +import { orderBy } from "lodash"; + +export interface IWebhookLogsProps { + handleClose: IModalProps["onClose"]; + webhookObject: IWebhook; +} + +export default function WebhookModal({ + handleClose, + webhookObject, +}: IWebhookLogsProps) { + const { tableState } = useProjectContext(); + const [logsCollection, logsDispatch] = useCollection({}); + useEffect(() => { + if (webhookObject && tableState?.tablePath) { + logsDispatch({ + path: "_rowy_/webhooks/logs", + filters: [ + { + field: "params.endpoint", + operator: "==", + value: webhookObject.endpoint, + }, + { + field: "params.tablePath", + operator: "==", + value: tableState?.tablePath, + }, + ], + orderBy: { key: "createdAt", direction: "desc" }, + limit: 50, + }); + } + }, [webhookObject, tableState?.tablePath]); + return ( + + {logsCollection.documents.map((doc) => ( + {`${doc.createdAt.toDate()} - ${ + doc.response + }`} + ))} + + } + actions={{ + primary: { + onClick: () => {}, + }, + }} + /> + ); +} diff --git a/src/components/Table/TableHeader/Webhooks/WebhookModal.tsx b/src/components/Table/TableHeader/Webhooks/WebhookModal.tsx new file mode 100644 index 00000000..8aef63e6 --- /dev/null +++ b/src/components/Table/TableHeader/Webhooks/WebhookModal.tsx @@ -0,0 +1,255 @@ +import { useState } from "react"; +import _isEqual from "lodash/isEqual"; +import _upperFirst from "lodash/upperFirst"; +import useStateRef from "react-usestateref"; + +import { + Grid, + TextField, + FormControlLabel, + Switch, + Stepper, + Step, + StepButton, + StepContent, + Typography, + Link, +} from "@mui/material"; +import ExpandIcon from "@mui/icons-material/KeyboardArrowDown"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; + +import Modal, { IModalProps } from "@src/components/Modal"; +import Step1Secret from "./Step1Secret"; +import Step2Conditions from "./Step2Conditions"; +import Step3Body from "./Step3Parser"; + +import { useConfirmation } from "@src/components/ConfirmationDialog"; + +import { webhookNames, IWebhook } from "./utils"; +import { WIKI_LINKS } from "@src/constants/externalLinks"; + +type StepValidation = Record<"condition" | "parser", boolean>; +export interface IWebhookModalStepProps { + webhookObject: IWebhook; + setWebhookObject: React.Dispatch>; + validation: StepValidation; + setValidation: React.Dispatch>; + validationRef: React.RefObject; +} + +export interface IWebhookModalProps { + handleClose: IModalProps["onClose"]; + handleAdd: (webhookObject: IWebhook) => void; + handleUpdate: (webhookObject: IWebhook) => void; + mode: "add" | "update"; + webhookObject: IWebhook; +} + +export default function WebhookModal({ + handleClose, + handleAdd, + handleUpdate, + mode, + webhookObject: initialObject, +}: IWebhookModalProps) { + const { requestConfirmation } = useConfirmation(); + + const [webhookObject, setWebhookObject] = useState(initialObject); + + const [activeStep, setActiveStep] = useState(0); + + const [validation, setValidation, validationRef] = + useStateRef({ condition: true, parser: true }); + + const edited = !_isEqual(initialObject, webhookObject); + + const handleAddOrUpdate = () => { + if (mode === "add") handleAdd(webhookObject); + if (mode === "update") handleUpdate(webhookObject); + }; + + const stepProps = { + webhookObject, + setWebhookObject, + validation, + setValidation, + validationRef, + }; + + return ( + + + + { + setWebhookObject({ + ...webhookObject, + name: event.target.value, + }); + }} + /> + + + + setWebhookObject((webhookObject) => ({ + ...webhookObject, + active: e.target.checked, + })) + } + size="medium" + /> + } + label={`Webhook endpoint is ${ + !webhookObject.active ? "de" : "" + }activated`} + /> + + + + theme.transitions.create("transform"), + }, + "& .Mui-active svg": { + transform: "rotate(180deg)", + }, + }} + > + + setActiveStep(0)}> + Verification + + + + + Set the verification secret for the webhook. + + + + + + + setActiveStep(1)}> + Conditions (optional) + + + + + Optionally, write a function that determines if the webhook + call should be processed. Leave the function to always return{" "} + true if you do not want to write additional + logic. + + + + + + + setActiveStep(2)}> + Parser + + + + + Write the webhook parsed function. The returned object of the + parser will be added as new row{" "} + + Docs + + + + + + + + + } + actions={{ + primary: { + children: mode === "add" ? "Add" : "Update", + disabled: !edited || !webhookObject.name.length, + onClick: () => { + let warningMessage; + if (!validation.condition && !validation.parser) { + warningMessage = "Condition and webhook body are not valid"; + } else if (!validation.condition) { + warningMessage = "Condition is not valid"; + } else if (!validation.parser) { + warningMessage = "Webhook body is not valid"; + } + if (warningMessage) { + requestConfirmation({ + title: "Validation failed", + body: `${warningMessage}. Continue?`, + confirm: "Yes, I know what I’m doing", + cancel: "No, I’ll fix the errors", + handleConfirm: handleAddOrUpdate, + }); + } else { + handleAddOrUpdate(); + } + }, + }, + }} + /> + ); +} diff --git a/src/components/Table/TableHeader/Webhooks/index.tsx b/src/components/Table/TableHeader/Webhooks/index.tsx new file mode 100644 index 00000000..d9832855 --- /dev/null +++ b/src/components/Table/TableHeader/Webhooks/index.tsx @@ -0,0 +1,244 @@ +import { useState } from "react"; +import _isEqual from "lodash/isEqual"; + +import { Breadcrumbs } from "@mui/material"; + +import TableHeaderButton from "../TableHeaderButton"; +import WebhookIcon from "@src/assets/icons/Webhook"; +import Modal from "@src/components/Modal"; +import WebhookList from "./WebhookList"; +import WebhookModal from "./WebhookModal"; +import WebhookLogs from "./WebhookLogs"; + +import { useProjectContext } from "@src/contexts/ProjectContext"; +import { useAppContext } from "@src/contexts/AppContext"; +import { useConfirmation } from "@src/components/ConfirmationDialog"; + +import { emptyWebhookObject, IWebhook, WebhookType } from "./utils"; +import { runRoutes } from "@src/constants/runRoutes"; +import { analytics } from "@src/analytics"; +import { useSnackbar } from "notistack"; + +export default function Webhooks() { + const { tableState, tableActions, rowyRun, compatibleRowyRunVersion } = + useProjectContext(); + const appContext = useAppContext(); + const { requestConfirmation } = useConfirmation(); + const { enqueueSnackbar } = useSnackbar(); + + const currentWebhooks = (tableState?.config.webhooks ?? []) as IWebhook[]; + const [localWebhooksObjects, setLocalWebhooksObjects] = + useState(currentWebhooks); + const [openWebhookList, setOpenWebhookList] = useState(false); + const [webhookModal, setWebhookModal] = useState<{ + mode: "add" | "update"; + webhookObject: IWebhook; + index?: number; + } | null>(null); + const [webhookLogs, setWebhookLogs] = useState(); + if (!compatibleRowyRunVersion?.({ minVersion: "1.1.1" })) return <>; + const edited = !_isEqual(currentWebhooks, localWebhooksObjects); + + const tablePathTokens = + tableState?.tablePath?.split("/").filter(function (_, i) { + // replace IDs with dash that appears at even indexes + return i % 2 === 0; + }) ?? []; + + const handleOpen = () => { + setOpenWebhookList(true); + }; + + const handleClose = () => { + if (edited) { + requestConfirmation({ + title: "Discard changes", + body: "You will lose changes you have made to webhooks", + confirm: "Discard", + handleConfirm: () => { + setLocalWebhooksObjects(currentWebhooks); + setOpenWebhookList(false); + }, + }); + } else { + setOpenWebhookList(false); + } + }; + + const handleSaveWebhooks = async () => { + tableActions?.table.updateConfig("webhooks", localWebhooksObjects); + setOpenWebhookList(false); + // TODO: convert to async function that awaits for the document write to complete + await new Promise((resolve) => setTimeout(resolve, 500)); + }; + + const handleSaveDeploy = async () => { + await handleSaveWebhooks(); + try { + if (rowyRun) { + const resp = await rowyRun({ + route: runRoutes.publishWebhooks, + body: { + tableConfigPath: tableState?.config.tableConfig.path, + tablePath: tableState?.tablePath, + }, + }); + enqueueSnackbar(resp.message, { + variant: resp.success ? "success" : "error", + }); + + analytics.logEvent("published_webhooks"); + } + } catch (e) { + console.error(e); + } + }; + + const handleAddWebhook = (webhookObject: IWebhook) => { + setLocalWebhooksObjects([...localWebhooksObjects, webhookObject]); + analytics.logEvent("created_webhook", { type: webhookObject.type }); + setWebhookModal(null); + }; + + const handleUpdateWebhook = (webhookObject: IWebhook) => { + setLocalWebhooksObjects( + localWebhooksObjects.map((webhook, index) => { + if (index === webhookModal?.index) { + return { + ...webhookObject, + lastEditor: currentEditor(), + }; + } else { + return webhook; + } + }) + ); + analytics.logEvent("updated_webhook", { type: webhookObject.type }); + setWebhookModal(null); + }; + + const handleUpdateActive = (index: number, active: boolean) => { + setLocalWebhooksObjects( + localWebhooksObjects.map((webhookObject, i) => { + if (i === index) { + return { + ...webhookObject, + active, + lastEditor: currentEditor(), + }; + } else { + return webhookObject; + } + }) + ); + }; + + const handleOpenLogs = (index: number) => { + const _webhook = localWebhooksObjects[index]; + + setWebhookLogs(_webhook); + analytics.logEvent("view_webhook_logs", { + type: _webhook.type, + }); + }; + + const handleEdit = (index: number) => { + setWebhookModal({ + mode: "update", + webhookObject: localWebhooksObjects[index], + index, + }); + }; + + const handleDelete = (index: number) => { + requestConfirmation({ + title: `Delete ${localWebhooksObjects[index].name}?`, + body: "This webhook will be permanently deleted.", + confirm: "Confirm", + handleConfirm: () => { + setLocalWebhooksObjects( + localWebhooksObjects.filter((_, i) => i !== index) + ); + }, + }); + }; + + const currentEditor = () => ({ + displayName: appContext?.currentUser?.displayName ?? "Unknown user", + photoURL: appContext?.currentUser?.photoURL ?? "", + lastUpdate: Date.now(), + }); + + return ( + <> + } + /> + + {openWebhookList && !!tableState && ( + + + {tablePathTokens.map((pathToken) => ( + {pathToken} + ))} + + { + setWebhookModal({ + mode: "add", + webhookObject: emptyWebhookObject(type, currentEditor()), + }); + }} + handleUpdateActive={handleUpdateActive} + handleEdit={handleEdit} + handleOpenLogs={handleOpenLogs} + handleDelete={handleDelete} + /> + + } + actions={{ + primary: { + children: "Save & Deploy", + onClick: handleSaveDeploy, + disabled: !edited, + }, + secondary: { + children: "Save", + onClick: handleSaveWebhooks, + disabled: !edited, + }, + }} + /> + )} + + {webhookModal && ( + { + setWebhookModal(null); + }} + handleAdd={handleAddWebhook} + handleUpdate={handleUpdateWebhook} + mode={webhookModal.mode} + webhookObject={webhookModal.webhookObject} + /> + )} + {webhookLogs && ( + { + setWebhookLogs(null); + }} + /> + )} + + ); +} diff --git a/src/components/Table/TableHeader/Webhooks/utils.ts b/src/components/Table/TableHeader/Webhooks/utils.ts new file mode 100644 index 00000000..c191859e --- /dev/null +++ b/src/components/Table/TableHeader/Webhooks/utils.ts @@ -0,0 +1,105 @@ +import { generateRandomId } from "@src/utils/fns"; + +export const webhookTypes = [ + "basic", + "typeform", + "sendgrid", + "shopify", + "twitter", + "stripe", +] as const; + +export type WebhookType = typeof webhookTypes[number]; + +export const webhookNames: Record = { + sendgrid: "Sendgrid", + typeform: "Typeform", + shopify: "Shopify", + twitter: "Twitter", + stripe: "Stripe", + basic: "Basic", +}; + +export interface IWebhookEditor { + displayName: string; + photoURL: string; + lastUpdate: number; +} + +export interface IWebhook { + // rowy meta fields + name: string; + active: boolean; + lastEditor: IWebhookEditor; + // webhook specific fields + endpoint: string; + type: WebhookType; + parser: string; + conditions: string; + secret?: string; +} + +const parserTemplates = { + basic: `const basicParser: BasicParser = async({req, db,ref}) => { + // request is the request object from the webhook + // db is the database object + // ref is the reference to collection of the table + // the returned object will be added as a new row to the table + // eg: adding the webhook body as row + const {body} = req; + return body; +}`, + typeform: `const typeformParser: TypeformParser = async({req, db,ref}) =>{ + // this reduces the form submission into a single object of key value pairs + // eg: {name: "John", age: 20} + // ⚠️ ensure that you have assigned ref values of the fields + // set the ref value to field key you would like to sync to + // docs: https://help.typeform.com/hc/en-us/articles/360050447552-Block-reference-format-restrictions + const {submitted_at,hidden,answers} = req.body.form_response + return ({ + _createdAt: submitted_at, + ...hidden, + ...answers.reduce((accRow, currAnswer) => { + switch (currAnswer.type) { + case "date": + return { + ...accRow, + [currAnswer.field.ref]: new Date(currAnswer[currAnswer.type]), + }; + case "choice": + return { + ...accRow, + [currAnswer.field.ref]: currAnswer[currAnswer.type].label, + }; + case "choices": + return { + ...accRow, + [currAnswer.field.ref]: currAnswer[currAnswer.type].labels, + }; + case "file_url": + default: + return { + ...accRow, + [currAnswer.field.ref]: currAnswer[currAnswer.type], + }; + } + }, {}), + })};`, +}; +export function emptyWebhookObject( + type: WebhookType, + user: IWebhookEditor +): IWebhook { + return { + name: "Untitled webhook", + active: false, + endpoint: generateRandomId(), + type, + parser: parserTemplates[type] ?? parserTemplates["basic"], + conditions: `const condition: Condition = async({ref,req,db}) => { + // feel free to add your own code logic here + return true; +}`, + lastEditor: user, + }; +} diff --git a/src/components/Table/TableHeader/index.tsx b/src/components/Table/TableHeader/index.tsx index 6b91c4e5..e968e8fa 100644 --- a/src/components/Table/TableHeader/index.tsx +++ b/src/components/Table/TableHeader/index.tsx @@ -13,6 +13,7 @@ import CloudLogs from "./CloudLogs"; import HiddenFields from "../HiddenFields"; import RowHeight from "./RowHeight"; import Extensions from "./Extensions"; +import Webhooks from "./Webhooks"; import ReExecute from "./ReExecute"; import { useAppContext } from "@src/contexts/AppContext"; @@ -99,6 +100,7 @@ export default function TableHeader() { {userClaims?.roles?.includes("ADMIN") && ( <> {/* Spacer */}
+ diff --git a/src/components/Table/formatters/FinalColumn.tsx b/src/components/Table/formatters/FinalColumn.tsx index fa7d4278..04134d0b 100644 --- a/src/components/Table/formatters/FinalColumn.tsx +++ b/src/components/Table/formatters/FinalColumn.tsx @@ -28,11 +28,11 @@ export default function FinalColumn({ row }: FormatterProps) { useStyles(); const { requestConfirmation } = useConfirmation(); - const { tableActions, addRow } = useProjectContext(); + const { deleteRow, addRow } = useProjectContext(); const altPress = useKeyPress("Alt"); - - const handleDelete = () => tableActions!.row.delete(row.id); - + const handleDelete = () => { + if (deleteRow) deleteRow(row.id); + }; return ( @@ -48,7 +48,7 @@ export default function FinalColumn({ row }: FormatterProps) { Object.keys(clonedRow).forEach((key) => { if (clonedRow[key] === undefined) delete clonedRow[key]; }); - if (tableActions) addRow!(clonedRow); + if (addRow) addRow!(clonedRow); }} aria-label="Duplicate row" className="row-hover-iconButton" @@ -61,7 +61,6 @@ export default function FinalColumn({ row }: FormatterProps) { , ignoreRequiredFields?: boolean) => void; + deleteRow: (rowId) => void; updateCell: ( ref: firebase.firestore.DocumentReference, fieldName: string, @@ -71,6 +75,10 @@ interface IProjectContext { deleteTable: (id: string) => void; }; + compatibleRowyRunVersion: (args: { + minVersion?: string; + maxVersion?: string; + }) => boolean; // A ref to the data grid. Contains data grid functions dataGridRef: React.RefObject; // A ref to the side drawer state. Prevents unnecessary re-renders @@ -99,6 +107,17 @@ export const ProjectContextProvider: React.FC = ({ children }) => { const [settings, settingsActions] = useSettings(); const table = _find(tables, (table) => table.id === tableState.config.id); + const [rowyRunVersion, setRowyRunVersion] = useState(""); + useEffect(() => { + if (settings?.doc?.rowyRunUrl) { + _rowyRun({ + route: runRoutes.version, + }).then((resp) => { + if (resp.version) setRowyRunVersion(resp.version); + }); + } + }, [settings?.doc?.rowyRunUrl]); + useEffect(() => { const { tables } = settings; if (tables && userRoles) { @@ -133,7 +152,31 @@ export const ProjectContextProvider: React.FC = ({ children }) => { : [], [tables] ); - + const auditChange = ( + type: "ADD_ROW" | "UPDATE_CELL" | "DELETE_ROW", + rowId, + data + ) => { + if ( + table?.audit !== false && + compatibleRowyRunVersion({ minVersion: "1.1.1" }) + ) { + _rowyRun({ + route: runRoutes.auditChange, + body: { + rowyUser: rowyUser(currentUser!), + type, + ref: { + rowPath: tableState.tablePath, + rowId, + tableId: table?.id, + collectionPath: tableState.tablePath, + }, + data, + }, + }); + } + }; const addRow: IProjectContext["addRow"] = (data, ignoreRequiredFields) => { const valuesFromFilter = tableState.filters.reduce((acc, curr) => { if (curr.operator === "==") { @@ -170,7 +213,8 @@ export const ProjectContextProvider: React.FC = ({ children }) => { tableActions.row.add( { ...valuesFromFilter, ...initialData, ...data }, - ignoreRequiredFields ? [] : requiredFields + ignoreRequiredFields ? [] : requiredFields, + (rowId: string) => auditChange("ADD_ROW", rowId, {}) ); }; @@ -190,11 +234,11 @@ export const ProjectContextProvider: React.FC = ({ children }) => { { updatedField: fieldName } ); } - tableActions.row.update( ref, update, () => { + auditChange("UPDATE_CELL", ref.id, { updatedField: fieldName }); if (onSuccess) onSuccess(ref, fieldName, value); }, (error) => { @@ -210,6 +254,10 @@ export const ProjectContextProvider: React.FC = ({ children }) => { } ); }; + + const deleteRow = (rowId) => { + tableActions.row.delete(rowId, () => auditChange("DELETE_ROW", rowId, {})); + }; // rowyRun access const _rowyRun: IProjectContext["rowyRun"] = async (args) => { const authToken = await getAuthToken(); @@ -237,6 +285,20 @@ export const ProjectContextProvider: React.FC = ({ children }) => { } }; + const compatibleRowyRunVersion = ({ + minVersion, + maxVersion, + }: { + minVersion?: string; + maxVersion?: string; + }) => { + // example: "1.0.0", "1.0.0-beta.1", "1.0.0-rc.1+1" + const version = rowyRunVersion.split("-")[0]; + if (!version) return false; + if (minVersion && semver.lt(version, minVersion)) return false; + if (maxVersion && semver.gt(version, maxVersion)) return false; + return true; + }; // A ref to the data grid. Contains data grid functions const dataGridRef = useRef(null); const sideDrawerRef = useRef(); @@ -250,7 +312,9 @@ export const ProjectContextProvider: React.FC = ({ children }) => { tableActions, addRow, updateCell, + deleteRow, settingsActions, + settings: settings.doc, roles, tables, table, @@ -259,6 +323,7 @@ export const ProjectContextProvider: React.FC = ({ children }) => { columnMenuRef, importWizardRef, rowyRun: _rowyRun, + compatibleRowyRunVersion, }} > {children} diff --git a/src/firebase/firebaseui.ts b/src/firebase/firebaseui.ts index 64a75289..b7a9ae52 100644 --- a/src/firebase/firebaseui.ts +++ b/src/firebase/firebaseui.ts @@ -7,14 +7,6 @@ import githubLogo from "@src/assets/logos/github.svg"; import appleLogo from "@src/assets/logos/apple.svg"; import yahooLogo from "@src/assets/logos/yahoo.svg"; -import { mdiGoogle } from "@mdi/js"; -console.log( - `data:image/svg+xml;utf8,` + - encodeURIComponent( - `` - ) -); - export const authOptions = { google: { provider: firebase.auth.GoogleAuthProvider.PROVIDER_ID, diff --git a/src/hooks/useTable/index.ts b/src/hooks/useTable/index.ts index 22b25a73..6922e187 100644 --- a/src/hooks/useTable/index.ts +++ b/src/hooks/useTable/index.ts @@ -26,10 +26,10 @@ export type TableState = { id: string; rowHeight: number; tableConfig: any; - webhooks: any; sparks: string; compiledExtension: string; extensionObjects?: any[]; + webhooks?: any[]; functionConfigPath?: string; }; columns: any[]; diff --git a/src/hooks/useTable/useTableData.tsx b/src/hooks/useTable/useTableData.tsx index 5369dc1d..d8625916 100644 --- a/src/hooks/useTable/useTableData.tsx +++ b/src/hooks/useTable/useTableData.tsx @@ -231,12 +231,12 @@ const useTableData = () => { * @param rowIndex local position * @param documentId firestore document id */ - const deleteRow = (rowId: string) => { + const deleteRow = async (rowId: string, onSuccess: () => void) => { // Remove row locally rowsDispatch({ type: "delete", rowId }); // Delete document try { - db.collection(tableState.path).doc(rowId).delete(); + await db.collection(tableState.path).doc(rowId).delete().then(onSuccess); } catch (error: any) { console.log(error); if (error.code === "permission-denied") { @@ -264,7 +264,11 @@ const useTableData = () => { /** creating new document/row * @param data(optional: default will create empty row) */ - const addRow = async (data: any, requiredFields: string[]) => { + const addRow = ( + data: any, + requiredFields: string[], + onSuccess: (rowId: string) => void + ) => { const missingRequiredFields = requiredFields ? requiredFields.reduce(missingFieldsReducer(data), []) : []; @@ -274,7 +278,12 @@ const useTableData = () => { if (missingRequiredFields.length === 0) { try { - await db.collection(path).doc(newId).set(data, { merge: true }); + db.collection(path) + .doc(newId) + .set(data, { merge: true }) + .then(() => { + onSuccess(newId); + }); } catch (error: any) { if (error.code === "permission-denied") { enqueueSnackbar("You do not have the permissions to add new rows.", { diff --git a/src/utils/fns.ts b/src/utils/fns.ts index 3b337fd6..263168dd 100644 --- a/src/utils/fns.ts +++ b/src/utils/fns.ts @@ -199,6 +199,12 @@ export const rowyUser = ( ...data, }; }; +export const generateRandomId = () => { + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + ); +}; const _firestoreRefSanitizer = (v: any) => { // If react-hook-form receives a Firestore document reference, it tries to