Merge branch 'develop' into feat/cloud-logs

* develop:
  setup fixes
  auditing rr min version
  rowyRun version requirement
  fix monaco row definition
  auditing
  update cell audit logs
  remove components/Table/Settings
  remove old Table/Settings/Webhooks file
  standardize imports to use "@src/
  fns log
  basic logs
  typeform template
  removed endpoint step
  basic webhooks
This commit is contained in:
Sidney Alcantara
2021-11-04 15:27:12 +11:00
24 changed files with 1207 additions and 34 deletions

View File

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

View File

@@ -0,0 +1,10 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
import { mdiWebhook } from "@mdi/js";
export default function Webhook(props: SvgIconProps) {
return (
<SvgIcon {...props}>
<path d={mdiWebhook} />
</SvgIcon>
);
}

View File

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

View File

@@ -20,7 +20,7 @@ export default function Step2ServiceAccount({
setCompletion,
}: ISetupStepBodyProps) {
const [hasAllRoles, setHasAllRoles] = useState(completion.serviceAccount);
const [roles, setRoles] = useState<Record<string, any>>({});
// const [roles, setRoles] = useState<Record<string, any>>({});
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);

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ export interface IExtension {
active: boolean;
lastEditor: IExtensionEditor;
// ft build fields
// build fields
triggers: ExtensionTrigger[];
type: ExtensionType;
requiredFields: string[];

View File

@@ -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 (
<FormControl component="fieldset">
<FormLabel component="legend" className="visually-hidden">
Secret
</FormLabel>
<TextField
fullWidth
value={webhookObject.secret}
onChange={(e) =>
setWebhookObject({ ...webhookObject, secret: e.target.value })
}
/>
</FormControl>
);
}

View File

@@ -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 (
<>
<div>
<CodeEditor
value={webhookObject.conditions}
minHeight={200}
onChange={(newValue) => {
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)}
/>
</div>
<CodeEditorHelper
docLink={WIKI_LINKS.webhooks}
additionalVariables={additionalVariables}
/>
</>
);
}

View File

@@ -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 (
<>
<div>
<CodeEditor
value={webhookObject.parser}
minHeight={400}
onChange={(newValue) => {
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)}
/>
</div>
<CodeEditorHelper
docLink={
WIKI_LINKS[`webhooks${_upperFirst(webhookObject.type)}`] ||
WIKI_LINKS.webhooks
}
additionalVariables={additionalVariables}
/>
</>
);
}

View File

@@ -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 | HTMLElement>(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 (
<>
<Stack
direction="row"
spacing={2}
justifyContent="space-between"
alignItems="center"
style={{ marginTop: 0 }}
>
<Typography
variant="subtitle2"
component="h2"
style={{ fontFeatureSettings: "'case', 'tnum'" }}
>
Webhooks ({activeWebhookCount} / {webhooks.length})
</Typography>
<Button
color="primary"
startIcon={<AddIcon />}
onClick={handleAddButton}
ref={addButtonRef}
>
Add webhook
</Button>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
{webhookTypes.map((type) => (
<MenuItem onClick={() => handleChooseAddType(type)}>
{webhookNames[type]}
</MenuItem>
))}
</Menu>
</Stack>
{webhooks.length === 0 ? (
<ButtonBase
onClick={handleAddButton}
sx={{
width: "100%",
height: 72 * 3,
borderRadius: 1,
"&:hover": { bgcolor: "action.hover" },
}}
>
<EmptyState
message="Add your first webhook"
description="Your webhooks will appear here."
Icon={WebhookIcon}
/>
</ButtonBase>
) : (
<List style={{ paddingTop: 0, minHeight: 72 * 3 }}>
{webhooks.map((webhook, index) => (
<ListItem
disableGutters
dense={false}
divider={index !== webhooks.length - 1}
children={
<ListItemText
primary={
<>
{webhook.name} <code>{webhookNames[webhook.type]}</code>
</>
}
secondary={
<div style={{ display: "flex", alignItems: "center" }}>
<div
style={{
maxWidth: 340,
overflowX: "auto",
whiteSpace: "nowrap",
}}
>
<Typography variant="caption">
<code>
{baseUrl}
{webhook.endpoint}
</code>
</Typography>
</div>
<Tooltip title="copy to clipboard">
<IconButton
onClick={() =>
navigator.clipboard.writeText(
`${baseUrl}${webhook.endpoint}`
)
}
>
<CopyIcon />
</IconButton>
</Tooltip>
</div>
}
//secondary={webhookNames[webhook.type]}
primaryTypographyProps={{
style: {
minHeight: 40,
display: "flex",
alignItems: "center",
},
}}
/>
}
secondaryAction={
<Stack alignItems="flex-end">
<Stack direction="row" alignItems="center" spacing={1}>
<Tooltip title={webhook.active ? "Deactivate" : "Activate"}>
<Switch
checked={webhook.active}
onClick={() =>
handleUpdateActive(index, !webhook.active)
}
inputProps={{ "aria-label": "Activate" }}
sx={{ mr: 1 }}
/>
</Tooltip>
<Tooltip title="Logs">
<IconButton
aria-label="Logs"
onClick={() => handleOpenLogs(index)}
>
<LogsIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
aria-label="Edit"
onClick={() => handleEdit(index)}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
aria-label="Delete"
color="error"
onClick={() => handleDelete(index)}
sx={{ "&&": { mr: -1.5 } }}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
<Tooltip
title={
<>
Last updated
<br />
by {webhook.lastEditor.displayName}
<br />
at{" "}
{format(
webhook.lastEditor.lastUpdate,
DATE_TIME_FORMAT
)}
</>
}
>
<Stack direction="row" spacing={1} alignItems="center">
<Typography
variant="body2"
sx={{ color: "text.disabled" }}
>
{formatRelative(
webhook.lastEditor.lastUpdate,
new Date()
)}
</Typography>
<Avatar
alt={`${webhook.lastEditor.displayName}s profile photo`}
src={webhook.lastEditor.photoURL}
sx={{ width: 24, height: 24, "&&": { mr: -0.5 } }}
/>
</Stack>
</Tooltip>
</Stack>
}
/>
))}
</List>
)}
</>
);
}

View File

@@ -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 (
<Modal
onClose={handleClose}
disableBackdropClick
disableEscapeKeyDown
fullWidth
title={`Webhook logs: ${webhookObject.name}`}
sx={{
"& .MuiPaper-root": {
maxWidth: 742 + 20,
height: 980,
},
}}
children={
<>
{logsCollection.documents.map((doc) => (
<Typography>{`${doc.createdAt.toDate()} - ${
doc.response
}`}</Typography>
))}
</>
}
actions={{
primary: {
onClick: () => {},
},
}}
/>
);
}

View File

@@ -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<React.SetStateAction<IWebhook>>;
validation: StepValidation;
setValidation: React.Dispatch<React.SetStateAction<StepValidation>>;
validationRef: React.RefObject<StepValidation>;
}
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<IWebhook>(initialObject);
const [activeStep, setActiveStep] = useState(0);
const [validation, setValidation, validationRef] =
useStateRef<StepValidation>({ 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 (
<Modal
onClose={handleClose}
disableBackdropClick
disableEscapeKeyDown
fullWidth
title={`${mode === "add" ? "Add" : "Update"} webhook: ${
webhookNames[webhookObject.type]
}`}
sx={{
"& .MuiPaper-root": {
maxWidth: 742 + 20,
height: 980,
},
}}
children={
<>
<Grid
container
spacing={4}
justifyContent="center"
alignItems="center"
>
<Grid item xs={6}>
<TextField
size="small"
required
label="Webhook name"
variant="filled"
fullWidth
autoFocus
value={webhookObject.name}
error={edited && !webhookObject.name.length}
helperText={
edited && !webhookObject.name.length ? "Required" : " "
}
onChange={(event) => {
setWebhookObject({
...webhookObject,
name: event.target.value,
});
}}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
checked={webhookObject.active}
onChange={(e) =>
setWebhookObject((webhookObject) => ({
...webhookObject,
active: e.target.checked,
}))
}
size="medium"
/>
}
label={`Webhook endpoint is ${
!webhookObject.active ? "de" : ""
}activated`}
/>
</Grid>
</Grid>
<Stepper
nonLinear
activeStep={activeStep}
orientation="vertical"
sx={{
mt: 0,
"& .MuiStepLabel-root": { width: "100%" },
"& .MuiStepLabel-label": {
display: "flex",
width: "100%",
typography: "subtitle2",
"&.Mui-active": { typography: "subtitle2" },
},
"& .MuiStepLabel-label svg": {
display: "block",
marginLeft: "auto",
my: ((24 - 18) / 2 / 8) * -1,
transition: (theme) => theme.transitions.create("transform"),
},
"& .Mui-active svg": {
transform: "rotate(180deg)",
},
}}
>
<Step>
<StepButton onClick={() => setActiveStep(0)}>
Verification
<ExpandIcon />
</StepButton>
<StepContent>
<Typography gutterBottom>
Set the verification secret for the webhook.
</Typography>
<Step1Secret {...stepProps} />
</StepContent>
</Step>
<Step>
<StepButton onClick={() => setActiveStep(1)}>
Conditions (optional)
<ExpandIcon />
</StepButton>
<StepContent>
<Typography gutterBottom>
Optionally, write a function that determines if the webhook
call should be processed. Leave the function to always return{" "}
<code>true</code> if you do not want to write additional
logic.
</Typography>
<Step2Conditions {...stepProps} />
</StepContent>
</Step>
<Step>
<StepButton onClick={() => setActiveStep(2)}>
Parser
<ExpandIcon />
</StepButton>
<StepContent>
<Typography gutterBottom>
Write the webhook parsed function. The returned object of the
parser will be added as new row{" "}
<Link
href={
WIKI_LINKS[
`webhooks${_upperFirst(webhookObject.type)}`
] || WIKI_LINKS.webhooks
}
target="_blank"
rel="noopener noreferrer"
>
Docs
<InlineOpenInNewIcon />
</Link>
</Typography>
<Step3Body {...stepProps} />
</StepContent>
</Step>
</Stepper>
</>
}
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 Im doing",
cancel: "No, Ill fix the errors",
handleConfirm: handleAddOrUpdate,
});
} else {
handleAddOrUpdate();
}
},
},
}}
/>
);
}

View File

@@ -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<IWebhook | null>();
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 (
<>
<TableHeaderButton
title="Webhooks"
onClick={handleOpen}
icon={<WebhookIcon />}
/>
{openWebhookList && !!tableState && (
<Modal
onClose={handleClose}
maxWidth="sm"
fullWidth
title="Webhooks"
children={
<>
<Breadcrumbs aria-label="breadcrumb">
{tablePathTokens.map((pathToken) => (
<code>{pathToken}</code>
))}
</Breadcrumbs>
<WebhookList
webhooks={localWebhooksObjects}
handleAddWebhook={(type: WebhookType) => {
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 && (
<WebhookModal
handleClose={() => {
setWebhookModal(null);
}}
handleAdd={handleAddWebhook}
handleUpdate={handleUpdateWebhook}
mode={webhookModal.mode}
webhookObject={webhookModal.webhookObject}
/>
)}
{webhookLogs && (
<WebhookLogs
webhookObject={webhookLogs}
handleClose={() => {
setWebhookLogs(null);
}}
/>
)}
</>
);
}

View File

@@ -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<WebhookType, string> = {
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,
};
}

View File

@@ -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 */} <div />
<Webhooks />
<Extensions />
<CloudLogs />
<TableLogs />

View File

@@ -28,11 +28,11 @@ export default function FinalColumn({ row }: FormatterProps<any, any>) {
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 (
<Stack direction="row" spacing={0.5}>
<Tooltip title="Duplicate row">
@@ -48,7 +48,7 @@ export default function FinalColumn({ row }: FormatterProps<any, any>) {
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<any, any>) {
<IconButton
size="small"
color="inherit"
disabled={!tableActions}
onClick={
altPress
? handleDelete

View File

@@ -48,6 +48,7 @@ const WIKI_PATHS = {
extensionsSlackMessage: "/extensions/slack-message",
extensionsSendgridEmail: "/extensions/sendgrid-email",
extensionsTwilioMessage: "/extensions/twilio-message",
webhooks: "/webhooks",
};
export const WIKI_LINKS = _mapValues(
WIKI_PATHS,

View File

@@ -44,6 +44,7 @@ export const runRoutes = {
migrateFT2Rowy: { path: "/migrateFT2Rowy", method: "GET" } as RunRoute,
actionScript: { path: "/actionScript", method: "POST" } as RunRoute,
buildFunction: { path: "/buildFunction", method: "POST" } as RunRoute,
publishWebhooks: { path: "/publishWebhooks", method: "POST" } as RunRoute,
projectOwner: { path: "/projectOwner", method: "GET" } as RunRoute,
setOwnerRoles: { path: "/setOwnerRoles", method: "GET" } as RunRoute,
inviteUser: { path: "/inviteUser", method: "POST" } as RunRoute,
@@ -51,4 +52,6 @@ export const runRoutes = {
deleteUser: { path: "/deleteUser", method: "DELETE" } as RunRoute,
algoliaSearchKey: { path: `/algoliaSearchKey`, method: "GET" } as RunRoute,
algoliaAppId: { path: `/algoliaAppId`, method: "GET" } as RunRoute,
functionLogs: { path: `/functionLogs`, method: "GET" } as RunRoute,
auditChange: { path: `/auditChange`, method: "POST" } as RunRoute,
} as const;

View File

@@ -16,10 +16,10 @@ import { ColumnMenuRef } from "@src/components/Table/ColumnMenu";
import { ImportWizardRef } from "@src/components/Wizards/ImportWizard";
import { rowyRun, IRowyRunRequestProps } from "@src/utils/rowyRun";
import { FieldType } from "@src/constants/fields";
import { rowyUser } from "@src/utils/fns";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import { runRoutes } from "@src/constants/runRoutes";
import semver from "semver";
export type Table = {
id: string;
collection: string;
@@ -34,12 +34,16 @@ export type Table = {
};
interface IProjectContext {
settings: {
rowyRunUrl?: string;
};
tables: Table[];
table: Table;
roles: string[];
tableState: TableState;
tableActions: TableActions;
addRow: (data?: Record<string, any>, 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<DataGridHandle>;
// 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<DataGridHandle>(null);
const sideDrawerRef = useRef<SideDrawerRef>();
@@ -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}

View File

@@ -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(
`<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="${mdiGoogle}" /></svg>`
)
);
export const authOptions = {
google: {
provider: firebase.auth.GoogleAuthProvider.PROVIDER_ID,

View File

@@ -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[];

View File

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

View File

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