>({});
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})
+
+
+ }
+ onClick={handleAddButton}
+ ref={addButtonRef}
+ >
+ Add webhook…
+
+
+
+
+ {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