Merge branch 'data-layer-rewrite' of https://github.com/rowyio/rowy into data-layer-rewrite

This commit is contained in:
shamsmosowi
2022-06-02 10:24:06 +10:00
55 changed files with 4754 additions and 48 deletions

View File

@@ -21,6 +21,7 @@
"@rowy/multiselect": "^0.3.0",
"@tinymce/tinymce-react": "^3",
"algoliasearch": "^4.13.1",
"ansi-to-react": "^6.1.6",
"buffer": "^6.0.3",
"compare-versions": "^4.1.3",
"csv-parse": "^5.0.4",
@@ -39,6 +40,7 @@
"monaco-editor-auto-typings": "^0.4.0",
"notistack": "^2.0.4",
"path-browserify": "^1.0.1",
"pb-util": "^1.0.3",
"quicktype-core": "^6.0.71",
"react": "^18.0.0",
"react-color-palette": "^6.2.0",
@@ -58,6 +60,7 @@
"react-router-dom": "^6.3.0",
"react-router-hash-link": "^2.4.3",
"react-scripts": "^5.0.0",
"react-usestateref": "^1.0.8",
"remark-gfm": "^3.0.1",
"stream-browserify": "^3.0.0",
"swr": "^1.3.0",

View File

@@ -26,7 +26,7 @@ import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { FieldType } from "@src/constants/fields";
import { runRoutes } from "@src/constants/runRoutes";
import { useSnackbar } from "notistack";
import { getSchemaPath } from "@src/utils/table";
import { getTableSchemaPath } from "@src/utils/table";
export default function ColumnConfigModal({
handleClose,
@@ -193,7 +193,7 @@ export default function ColumnConfigModal({
body: {
tablePath: tableSettings.collection,
pathname: window.location.pathname,
tableConfigPath: getSchemaPath(tableSettings),
tableConfigPath: getTableSchemaPath(tableSettings),
},
});
},

View File

@@ -4,9 +4,11 @@ import { IColumnModalProps } from ".";
import Modal from "@src/components/Modal";
import FieldsDropdown from "./FieldsDropdown";
import { Alert, AlertTitle } from "@mui/material";
import { tableScope, updateColumnAtom } from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
import { getFieldProp } from "@src/components/fields";
import { analytics, logEvent } from "analytics";
export default function TypeChangeModal({
@@ -20,7 +22,20 @@ export default function TypeChangeModal({
<Modal
onClose={handleClose}
title="Change column type"
children={<FieldsDropdown value={newType} onChange={setType} />}
children={
<>
<FieldsDropdown value={newType} onChange={setType} />
{getFieldProp("dataType", column.type) !==
getFieldProp("dataType", newType) && (
<Alert severity="warning">
<AlertTitle>Potential data loss</AlertTitle>
{getFieldProp("name", newType)} has an incompatible data type.
Selecting this can result in data loss.
</Alert>
)}
</>
}
actions={{
primary: {
onClick: () => {

View File

@@ -34,7 +34,7 @@ import { runRoutes } from "@src/constants/runRoutes";
import { CONFIG } from "@src/config/dbPaths";
import { ROUTES } from "@src/constants/routes";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { getSchemaPath } from "@src/utils/table";
import { getTableSchemaPath } from "@src/utils/table";
const customComponents = {
tableName: {
@@ -119,7 +119,7 @@ export default function TableSettingsDialog() {
cancel: "Later",
handleConfirm: async () => {
const tablePath = data.collection;
const tableConfigPath = getSchemaPath(data);
const tableConfigPath = getTableSchemaPath(data);
if (hasExtensions) {
// find derivative, default value

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef } from "react";
import useStateRef from "react-usestateref";
import { throttle } from "lodash-es";
import { Box } from "@mui/material";
import BuildLogRow from "./BuildLogRow";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { isTargetInsideBox } from "@src/utils/ui";
export interface IBuildLogListProps
extends React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {
logs: Record<string, any>[];
status: string;
value: number;
index: number;
}
export default function BuildLogList({
logs,
status,
value,
index,
...props
}: IBuildLogListProps) {
// useStateRef is necessary to resolve the state syncing issue
// https://stackoverflow.com/a/63039797/12208834
const [liveStreaming, setLiveStreaming, liveStreamingStateRef] =
useStateRef(true);
const liveStreamingRef = useRef<any>();
const isActive = value === index;
const handleScroll = throttle(() => {
const target = document.querySelector("#live-stream-target")!;
const scrollBox = document.querySelector("#live-stream-scroll-box")!;
const liveStreamTargetVisible = isTargetInsideBox(target, scrollBox);
if (liveStreamTargetVisible !== liveStreamingStateRef.current) {
setLiveStreaming(liveStreamTargetVisible);
}
}, 500);
const scrollToLive = () => {
const liveStreamTarget = document.querySelector("#live-stream-target");
liveStreamTarget?.scrollIntoView?.({
behavior: "smooth",
});
};
useEffect(() => {
if (liveStreaming && isActive && status === "BUILDING") {
if (!liveStreamingRef.current) {
scrollToLive();
} else {
setTimeout(scrollToLive, 100);
}
}
}, [logs, value]);
useEffect(() => {
if (isActive) {
const liveStreamScrollBox = document.querySelector(
"#live-stream-scroll-box"
);
liveStreamScrollBox!.addEventListener("scroll", () => {
handleScroll();
});
}
}, [value]);
return (
<div
role="tabpanel"
hidden={!isActive}
id={`vertical-tabpanel-${index}`}
aria-labelledby={`vertical-tab-${index}`}
{...props}
style={{
width: "100%",
backgroundColor: "#1E1E1E",
...props.style,
}}
>
{value === index && (
<Box
p={3}
style={{ overflowY: "auto", maxHeight: "100%" }}
id="live-stream-scroll-box"
>
{Array.isArray(logs) &&
logs.map((log, index) => (
<BuildLogRow logRecord={log} index={index} key={index} />
))}
<div ref={liveStreamingRef} id="live-stream-target">
{status === "BUILDING" && (
<CircularProgressOptical sx={{ ml: 4, mt: 2 }} size={30} />
)}
</div>
<div style={{ height: 10 }} />
</Box>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { format } from "date-fns";
import { styled, Typography } from "@mui/material";
import Ansi from "ansi-to-react";
import { TIME_FORMAT } from "constants/dates";
const Root = styled("div")(({ theme }) => ({
...(theme.typography.caption as any),
fontFamily: theme.typography.fontFamilyMono,
// TODO:
color: "#CCC",
"& code": {
background: "none",
borderRadius: 0,
padding: 0,
},
"& .color-info": { color: theme.palette.info.light },
"& .color-error": { color: theme.palette.error.light },
}));
export interface IBuildLogRowProps {
logRecord: Record<string, any>;
index: number;
}
export default function BuildLogRow({ logRecord, index }: IBuildLogRowProps) {
return (
<Root>
<Typography
variant="inherit"
sx={{
float: "left",
width: "2em",
textAlign: "right",
pr: 2,
}}
>
{index}
</Typography>
<Typography
variant="inherit"
sx={{
lineBreak: "anywhere",
paddingLeft: "2em",
whiteSpace: "break-spaces",
userSelect: "text",
}}
>
<Ansi
className={logRecord.level === "info" ? "color-nfo" : "color-error"}
>
{format(logRecord.timestamp, TIME_FORMAT + ":ss")}
</Ansi>
{" "}
<Ansi>
{logRecord.log
.replaceAll("\\n", "\n")
.replaceAll("\\t", "\t")
.replaceAll("\\", "")}
</Ansi>
</Typography>
</Root>
);
}

View File

@@ -0,0 +1,210 @@
import { format, differenceInCalendarDays } from "date-fns";
import { useAtom } from "jotai";
import { get } from "lodash-es";
import {
styled,
Accordion as MuiAccordion,
AccordionSummary as MuiAccordionSummary,
Tooltip,
AccordionDetails,
List,
ListProps,
} from "@mui/material";
import SuccessIcon from "@mui/icons-material/Check";
import FailIcon from "@mui/icons-material/Close";
import HourglassIcon from "@mui/icons-material/HourglassEmpty";
import LogsIcon from "@src/assets/icons/CloudLogs";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import EmptyState from "@src/components/EmptyState";
import BuildLogList from "./BuildLogList";
import CloudLogSubheader from "@src/components/TableToolbar/CloudLogs/CloudLogSubheader";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import useBuildLogs from "./useBuildLogs";
import { cloudLogFiltersAtom } from "@src/components/TableToolbar/CloudLogs/utils";
import { globalScope } from "@src/atoms/globalScope";
const Accordion = styled(MuiAccordion)(({ theme }) => ({
background: "none",
marginTop: 0,
"&::before": { display: "none" },
...(theme.typography.caption as any),
fontFamily: theme.typography.fontFamilyMono,
}));
const AccordionSummary = styled(MuiAccordionSummary)(({ theme }) => ({
minHeight: 32,
alignItems: "flex-start",
padding: theme.spacing(0, 1.375, 0, 1.5),
borderRadius: theme.shape.borderRadius,
"&:hover": { backgroundColor: theme.palette.action.hover },
userSelect: "auto",
"&.Mui-expanded": {
backgroundColor: theme.palette.background.paper,
".MuiPaper-elevation24 &": {
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))",
},
"&::before": {
content: '""',
position: "absolute",
zIndex: -1,
top: 0,
right: 0,
bottom: 0,
left: 0,
borderRadius: "inherit",
transition: theme.transitions.create(["background-color"], {
duration: theme.transitions.duration.short,
}),
backgroundColor: theme.palette.action.hover,
},
"&:hover::before": { backgroundColor: theme.palette.action.selected },
"&.Mui-focusVisible::before": {
backgroundColor: theme.palette.action.disabledBackground,
},
position: "sticky",
zIndex: 1,
top: 0,
".MuiListSubheader-sticky ~ li &": { top: 32 },
},
"& svg": {
fontSize: 18,
height: 20,
},
"& .MuiAccordionSummary-content, & .MuiAccordionSummary-expandIconWrapper": {
marginTop: (32 - 20) / 2,
marginBottom: (32 - 20) / 2,
},
"& .MuiAccordionSummary-content": {
overflow: "hidden",
paddingRight: theme.spacing(1),
display: "flex",
alignItems: "flex-start",
gap: theme.spacing(0.5, 2),
"& > *": { flexShrink: 0 },
[theme.breakpoints.down("lg")]: {
flexWrap: "wrap",
paddingLeft: theme.spacing(18 / 8 + 2),
"& > :first-child": { marginLeft: theme.spacing((18 / 8 + 2) * -1) },
},
},
}));
export default function BuildLogs(props: Partial<ListProps>) {
const { logs, latestStatus } = useBuildLogs();
const [cloudLogFilters, setCloudLogFilters] = useAtom(
cloudLogFiltersAtom,
globalScope
);
if (!latestStatus)
return (
<EmptyState
Icon={LogsIcon}
message="No logs"
description="You have no cloud deploys for this table"
/>
);
const renderedLogItems: React.ReactNode[] = [];
for (let i = 0; i < logs.length; i++) {
const logEntry = logs[i];
const prevEntry = logs[i - 1];
// Group by day
const diff = differenceInCalendarDays(
Date.now(),
get(logEntry, "startTimeStamp") ?? 0
);
const prevDiff = differenceInCalendarDays(
Date.now(),
get(prevEntry, "startTimeStamp") ?? 0
);
if (diff !== prevDiff) {
renderedLogItems.push(
<CloudLogSubheader key={`${diff} days ago`}>
{diff === 0 ? "Today" : diff === 1 ? "Yesterday" : `${diff} days ago`}
</CloudLogSubheader>
);
}
renderedLogItems.push(
<li key={logEntry.startTimeStamp}>
<Accordion
disableGutters
elevation={0}
square
TransitionProps={{ unmountOnExit: true }}
expanded={cloudLogFilters.buildLogExpanded === i}
onChange={(_, expanded) =>
setCloudLogFilters((c) => ({
...c,
buildLogExpanded: expanded ? i : -1,
}))
}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`${logEntry.id}-content`}
id={`${logEntry.id}-header`}
>
{logEntry.status === "BUILDING" && (
<Tooltip title="Building">
<HourglassIcon />
</Tooltip>
)}
{logEntry.status === "SUCCESS" && (
<Tooltip title="Success">
<SuccessIcon color="success" />
</Tooltip>
)}
{logEntry.status === "FAIL" && (
<Tooltip title="Fail">
<FailIcon color="error" />
</Tooltip>
)}
{format(logEntry.startTimeStamp, DATE_TIME_FORMAT + ":ss.SSS X")}
</AccordionSummary>
<AccordionDetails style={{ paddingLeft: 0, paddingRight: 0 }}>
<BuildLogList
key={i}
value={cloudLogFilters.buildLogExpanded!}
index={i}
logs={logEntry?.fullLog}
status={logEntry?.status}
/>
</AccordionDetails>
</Accordion>
</li>
);
}
return (
<List
disablePadding
{...({ component: "ol" } as any)}
{...props}
sx={{ mx: -1.5, mt: 1.5, ...props.sx }}
>
{renderedLogItems}
</List>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useRef, useState } from "react";
import { throttle } from "lodash-es";
import { useSetAtom } from "jotai";
import { Typography, Box, Tooltip, IconButton } from "@mui/material";
import ExpandIcon from "@mui/icons-material/ExpandLess";
import CollapseIcon from "@mui/icons-material/ExpandMore";
import OpenIcon from "@mui/icons-material/Fullscreen";
import CloseIcon from "@mui/icons-material/Close";
import BuildLogRow from "./BuildLogRow";
import CircularProgressOptical from "@src/components/CircularProgressOptical";
import { isTargetInsideBox } from "@src/utils/ui";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import useBuildLogs from "./useBuildLogs";
import { globalScope, tableModalAtom } from "@src/atoms/globalScope";
import { cloudLogFiltersAtom } from "@src/components/TableToolbar/CloudLogs/utils";
export interface IBuildLogsSnackProps {
onClose: () => void;
onOpenPanel: () => void;
}
export default function BuildLogsSnack({
onClose,
onOpenPanel,
}: IBuildLogsSnackProps) {
const snackLogContext = useSnackLogContext();
const { latestLog } = useBuildLogs();
const setModal = useSetAtom(tableModalAtom, globalScope);
const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, globalScope);
const latestActiveLog =
latestLog?.startTimeStamp > snackLogContext.latestBuildTimestamp - 5000 ||
latestLog?.startTimeStamp > snackLogContext.latestBuildTimestamp + 5000
? latestLog
: null;
const logs = latestActiveLog?.fullLog;
const status = latestActiveLog?.status;
const [expanded, setExpanded] = useState(false);
const [liveStreaming, setLiveStreaming] = useState(true);
const liveStreamingRef = useRef<any>();
const handleScroll = throttle(() => {
const target = document.querySelector("#live-stream-target-snack");
const scrollBox = document.querySelector("#live-stream-scroll-box-snack");
const liveStreamTargetVisible =
target && scrollBox && isTargetInsideBox(target, scrollBox);
setLiveStreaming(Boolean(liveStreamTargetVisible));
}, 100);
const scrollToLive = () => {
const liveStreamTarget = document.querySelector(
"#live-stream-target-snack"
);
liveStreamTarget?.scrollIntoView?.();
};
useEffect(() => {
if (liveStreaming && status === "BUILDING") {
if (!liveStreamingRef.current) {
scrollToLive();
} else {
setTimeout(scrollToLive, 500);
}
}
}, [latestActiveLog]);
useEffect(() => {
const liveStreamScrollBox = document.querySelector(
"#live-stream-scroll-box-snack"
);
liveStreamScrollBox!.addEventListener("scroll", () => {
handleScroll();
});
}, []);
return (
<Box
sx={{
position: "absolute",
left: (theme) => theme.spacing(2),
bottom: (theme) => theme.spacing(2),
backgroundColor: "#282829",
boxShadow: 6,
color: "#fff",
width: 650,
p: 2,
pt: 1,
borderRadius: 1,
zIndex: 1,
transition: (theme) => theme.transitions.create("height"),
height: expanded ? "calc(100% - 300px)" : 300,
}}
>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography
variant="subtitle2"
color={
latestActiveLog?.status === "SUCCESS"
? "success.light"
: latestActiveLog?.status === "FAIL"
? "error.light"
: ""
}
>
{!latestActiveLog && "Build pending…"}
{latestActiveLog?.status === "SUCCESS" && "Build success"}
{latestActiveLog?.status === "FAIL" && "Build failed"}
{latestActiveLog?.status === "BUILDING" && "Building…"}
</Typography>
<Box>
<Tooltip title="Expand">
<IconButton
aria-label="Expand"
size="small"
onClick={() => setExpanded(!expanded)}
style={{ color: "white" }}
>
{expanded ? <CollapseIcon /> : <ExpandIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Full screen">
<IconButton
aria-label="Full screen"
size="small"
onClick={() => {
setModal("cloudLogs");
setCloudLogFilters({
type: "build",
timeRange: { type: "days", value: 7 },
buildLogExpanded: 0,
});
}}
style={{ color: "white" }}
>
<OpenIcon />
</IconButton>
</Tooltip>
<Tooltip title="Close">
<IconButton
aria-label="Close"
size="small"
onClick={onClose}
style={{ color: "white" }}
>
<CloseIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box
sx={{
overflowY: "scroll",
maxHeight: "100%",
}}
height={"calc(100% - 25px)"}
id="live-stream-scroll-box-snack"
>
{latestActiveLog && (
<>
{logs?.map((log: any, index: number) => (
<BuildLogRow logRecord={log} index={index} key={index} />
))}
<div ref={liveStreamingRef} id="live-stream-target-snack">
{status === "BUILDING" && (
<CircularProgressOptical sx={{ ml: 4, mt: 2 }} size={30} />
)}
</div>
<div style={{ height: 10 }} />
</>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./BuildLogs";
export { default } from "./BuildLogs";

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from "react";
import { useAtom } from "jotai";
import useMemoValue from "use-memo-value";
import {
query,
collection,
orderBy,
limit,
queryEqual,
onSnapshot,
DocumentData,
} from "firebase/firestore";
import { globalScope } from "@src/atoms/globalScope";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope";
export default function useBuildLogs() {
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const functionConfigPath = tableSchema.functionConfigPath;
const [logs, setLogs] = useState<DocumentData[]>([]);
const logsQuery = useMemoValue(
functionConfigPath
? query(
collection(firebaseDb, `${functionConfigPath}/buildLogs`),
orderBy("timestamp", "desc"),
limit(15)
)
: null,
(next, prev) => queryEqual(next as any, prev as any)
);
useEffect(() => {
if (!logsQuery) return;
const unsubscribe = onSnapshot(logsQuery, (snapshot) => {
setLogs(snapshot.docs.map((doc) => doc.data()));
});
return unsubscribe;
}, [logsQuery]);
const latestLog = logs[0];
const latestStatus = latestLog?.status as string;
return { logs, latestLog, latestStatus };
}

View File

@@ -0,0 +1,257 @@
import { format } from "date-fns";
import { get } from "lodash-es";
import ReactJson from "react-json-view";
import { struct } from "pb-util";
import stringify from "json-stable-stringify-without-jsonify";
import {
styled,
useTheme,
Accordion as MuiAccordion,
AccordionSummary as MuiAccordionSummary,
AccordionDetails as MuiAccordionDetails,
Stack,
Chip as MuiChip,
ChipProps,
Typography,
Divider,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import CloudLogSeverityIcon from "./CloudLogSeverityIcon";
import { DATE_FORMAT, TIME_FORMAT } from "@src/constants/dates";
const Accordion = styled(MuiAccordion)(({ theme }) => ({
background: "none",
marginTop: 0,
"&::before": { display: "none" },
...(theme.typography.caption as any),
fontFamily: theme.typography.fontFamilyMono,
}));
const AccordionSummary = styled(MuiAccordionSummary)(({ theme }) => ({
minHeight: 32,
alignItems: "flex-start",
padding: theme.spacing(0, 1.375, 0, 1.5),
borderRadius: theme.shape.borderRadius,
"&:hover": { backgroundColor: theme.palette.action.hover },
userSelect: "auto",
"&.Mui-expanded": {
backgroundColor: theme.palette.background.paper,
".MuiPaper-elevation24 &": {
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))",
},
"&::before": {
content: '""',
position: "absolute",
zIndex: -1,
top: 0,
right: 0,
bottom: 0,
left: 0,
borderRadius: "inherit",
transition: theme.transitions.create(["background-color"], {
duration: theme.transitions.duration.short,
}),
backgroundColor: theme.palette.action.hover,
},
"&:hover::before": { backgroundColor: theme.palette.action.selected },
"&.Mui-focusVisible::before": {
backgroundColor: theme.palette.action.disabledBackground,
},
position: "sticky",
zIndex: 1,
top: 0,
".MuiListSubheader-sticky ~ li &": { top: 32 },
},
"& svg": {
fontSize: 18,
height: 20,
},
"& .MuiAccordionSummary-content, & .MuiAccordionSummary-expandIconWrapper": {
marginTop: (32 - 20) / 2,
marginBottom: (32 - 20) / 2,
},
"& .MuiAccordionSummary-content": {
overflow: "hidden",
paddingRight: theme.spacing(1),
display: "flex",
alignItems: "flex-start",
gap: theme.spacing(0.5, 2),
"& > *": { flexShrink: 0 },
[theme.breakpoints.down("lg")]: {
flexWrap: "wrap",
paddingLeft: theme.spacing(18 / 8 + 2),
"& > :first-child": { marginLeft: theme.spacing((18 / 8 + 2) * -1) },
},
},
"& .log-preview": { flexShrink: 1 },
}));
const Chip = styled((props: ChipProps) => <MuiChip size="small" {...props} />)({
font: "inherit",
minHeight: 20,
padding: 0,
cursor: "inherit",
});
const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({
paddingLeft: theme.spacing(18 / 8 + 2 + 1.5),
paddingRight: theme.spacing(18 / 8 + 2 + 1.5),
}));
export interface ICloudLogItemProps {
// https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#FIELDS.insert_id
data: Record<string, any>;
chips?: string[];
}
export default function CloudLogItem({
data: dataProp,
chips,
}: ICloudLogItemProps) {
const theme = useTheme();
const data = { ...dataProp };
if (dataProp.payload === "jsonPayload" && dataProp.jsonPayload)
data.jsonPayload = struct.decode(dataProp.jsonPayload ?? {});
const timestamp = new Date(
data.timestamp.seconds * 1000 + data.timestamp.nanos / 1_000_000
);
const renderedChips = Array.isArray(chips)
? chips
.map((key) => {
const value = get(data, key);
if (!value) return null;
return (
<Chip
key={key}
label={
typeof value === "string" || typeof value === "number"
? value
: JSON.stringify(value)
}
aria-describedby={key}
/>
);
})
.filter(Boolean)
: [];
return (
<Accordion
disableGutters
elevation={0}
square
TransitionProps={{ unmountOnExit: true }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`${data.insertId}-content`}
id={`${data.insertId}-header`}
>
<CloudLogSeverityIcon severity={data.severity} />
<time dateTime={timestamp.toISOString()}>
<Typography variant="inherit" color="text.secondary" component="span">
{format(timestamp, DATE_FORMAT)}
</Typography>{" "}
<Typography variant="inherit" fontWeight="bold" component="span">
{format(timestamp, TIME_FORMAT)}
</Typography>
<Typography variant="inherit" color="text.secondary" component="span">
{format(timestamp, ":ss.SSS X")}
</Typography>
</time>
{renderedChips.length > 0 && (
<Stack direction="row" spacing={0.75}>
{renderedChips}
</Stack>
)}
<Typography variant="inherit" noWrap className="log-preview">
{data.payload === "textPayload" && data.textPayload}
{get(data, "httpRequest.requestUrl")?.split(".run.app").pop()}
{data.payload === "jsonPayload" && (
<Typography
variant="inherit"
color="error"
fontWeight="bold"
component="span"
>
{data.jsonPayload.error}{" "}
</Typography>
)}
{data.payload === "jsonPayload" &&
stringify(data.jsonPayload.body ?? data.jsonPayload, {
space: 2,
})}
</Typography>
</AccordionSummary>
<AccordionDetails>
{data.payload === "textPayload" && (
<Typography variant="inherit" style={{ whiteSpace: "pre-wrap" }}>
{data.textPayload}
</Typography>
)}
{data.payload === "jsonPayload" && (
<>
{data.payload === "jsonPayload" && data.jsonPayload.error && (
<Typography
variant="inherit"
color="error"
fontWeight="bold"
paragraph
style={{ whiteSpace: "pre-wrap" }}
>
{data.jsonPayload.error}
</Typography>
)}
<ReactJson
src={data.jsonPayload.body ?? data.jsonPayload}
name={data.jsonPayload.body ? "body" : "jsonPayload"}
theme={theme.palette.mode === "dark" ? "monokai" : "rjv-default"}
iconStyle="triangle"
style={{ font: "inherit", backgroundColor: "transparent" }}
displayDataTypes={false}
quotesOnKeys={false}
sortKeys
/>
</>
)}
{data.payload && <Divider sx={{ my: 1 }} />}
<ReactJson
src={data}
collapsed={!!data.payload}
name="Full log entry"
theme={theme.palette.mode === "dark" ? "monokai" : "rjv-default"}
iconStyle="triangle"
style={{ font: "inherit", backgroundColor: "transparent" }}
displayDataTypes={false}
quotesOnKeys={false}
sortKeys
/>
</AccordionDetails>
</Accordion>
);
}

View File

@@ -0,0 +1,85 @@
import { get } from "lodash-es";
import { differenceInCalendarDays } from "date-fns";
import { List, ListProps } from "@mui/material";
import CloudLogSubheader from "./CloudLogSubheader";
import CloudLogItem from "./CloudLogItem";
export interface ICloudLogListProps extends Partial<ListProps> {
items: Record<string, any>[];
}
export default function CloudLogList({ items, ...props }: ICloudLogListProps) {
const renderedLogItems: React.ReactNode[] = [];
if (Array.isArray(items)) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
const prevItem = items[i - 1];
// Group by function execution ID if available
if (item.labels.execution_id) {
if (
get(item, "labels.execution_id") !==
get(prevItem, "labels.execution_id")
) {
renderedLogItems.push(
<CloudLogSubheader key={get(item, "labels.execution_id")}>
Function <code>{get(item, "resource.labels.function_name")}</code>{" "}
execution <code>{get(item, "labels.execution_id")}</code>
</CloudLogSubheader>
);
}
}
// Otherwise, group by day
else {
const diff = differenceInCalendarDays(
Date.now(),
(get(item, "timestamp.seconds") ?? 0) * 1000
);
const prevDiff = differenceInCalendarDays(
Date.now(),
(get(prevItem, "timestamp.seconds") ?? 0) * 1000
);
if (diff !== prevDiff) {
renderedLogItems.push(
<CloudLogSubheader key={`${diff} days ago`}>
{diff === 0
? "Today"
: diff === 1
? "Yesterday"
: `${diff} days ago`}
</CloudLogSubheader>
);
}
}
renderedLogItems.push(
<li key={item.insertId}>
<CloudLogItem
data={item}
chips={[
// Rowy Run HTTP request
"httpRequest.requestMethod",
"httpRequest.status",
// Rowy audit logs
"jsonPayload.type",
// "jsonPayload.ref.tableId",
"jsonPayload.ref.rowId",
"jsonPayload.data.updatedField",
"jsonPayload.rowyUser.displayName",
// Webhook event
"jsonPayload.params.endpoint",
]}
/>
</li>
);
}
}
return (
<List disablePadding {...({ component: "ol" } as any)} {...props}>
{renderedLogItems}
</List>
);
}

View File

@@ -0,0 +1,100 @@
import { SvgIcon, SvgIconProps, Tooltip } from "@mui/material";
import DebugIcon from "@mui/icons-material/BugReportOutlined";
import InfoIcon from "@mui/icons-material/InfoOutlined";
import NoticeIcon from "@mui/icons-material/NotificationsOutlined";
import WarningIcon from "@mui/icons-material/WarningAmberOutlined";
import ErrorIcon from "@mui/icons-material/ErrorOutline";
import { mdiCarBrakeAlert } from "@mdi/js";
import AlertIcon from "@mui/icons-material/Error";
import EmergencyIcon from "@mui/icons-material/NewReleases";
// https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity
export const SEVERITY_LEVELS = {
DEFAULT: "The log entry has no assigned severity level.",
DEBUG: "Debug or trace information.",
INFO: "Routine information, such as ongoing status or performance.",
NOTICE:
"Normal but significant events, such as start up, shut down, or a configuration change.",
WARNING: "Warning events might cause problems.",
ERROR: "Error events are likely to cause problems.",
CRITICAL: "Critical events cause more severe problems or outages.",
ALERT: "A person must take an action immediately.",
EMERGENCY: "One or more systems are unusable.",
};
export interface ICloudLogSeverityIconProps extends SvgIconProps {
severity: keyof typeof SEVERITY_LEVELS;
}
export default function CloudLogSeverityIcon({
severity,
...props
}: ICloudLogSeverityIconProps) {
const commonIconProps: SvgIconProps = {
...props,
"aria-hidden": "false",
"aria-label": `Severity: ${severity.toLowerCase()}`,
};
let icon = (
<SvgIcon {...commonIconProps} color="disabled" viewBox="0 0 18 18">
<circle cx="9" cy="9" r="2" />
</SvgIcon>
);
switch (severity) {
case "DEBUG":
icon = <DebugIcon {...commonIconProps} color="action" />;
break;
case "INFO":
icon = <InfoIcon {...commonIconProps} color="info" />;
break;
case "NOTICE":
icon = <NoticeIcon {...commonIconProps} color="info" />;
break;
case "WARNING":
icon = <WarningIcon {...commonIconProps} color="warning" />;
break;
case "ERROR":
icon = <ErrorIcon {...commonIconProps} color="error" />;
break;
case "CRITICAL":
icon = (
<SvgIcon {...commonIconProps} color="error">
<path d={mdiCarBrakeAlert} />
</SvgIcon>
);
break;
case "ALERT":
icon = <AlertIcon {...commonIconProps} color="error" />;
break;
case "EMERGENCY":
icon = <EmergencyIcon {...commonIconProps} color="error" />;
break;
default:
break;
}
return (
<Tooltip
title={
<>
{severity}
<br />
{SEVERITY_LEVELS[severity]}
</>
}
describeChild
>
{icon}
</Tooltip>
);
}

View File

@@ -0,0 +1,21 @@
import { styled, ListSubheader, ListSubheaderProps } from "@mui/material";
export const CloudLogSubheader = styled((props: ListSubheaderProps) => (
<ListSubheader disableGutters disableSticky={false} {...props} />
))(({ theme }) => ({
zIndex: 2,
"&:not(:first-child)": { marginTop: theme.spacing(2) },
...(theme.typography.subtitle2 as any),
padding: theme.spacing((32 - 20) / 2 / 8, 1.5),
lineHeight: "20px",
"& code": { fontSize: "90%" },
".MuiPaper-elevation24 &": {
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))",
},
}));
export default CloudLogSubheader;

View File

@@ -0,0 +1,39 @@
import { useAtom, useSetAtom } from "jotai";
import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton";
import LogsIcon from "@src/assets/icons/CloudLogs";
import CloudLogsModal from "./CloudLogsModal";
import {
globalScope,
projectSettingsAtom,
rowyRunModalAtom,
tableModalAtom,
} from "@src/atoms/globalScope";
export default function CloudLogs() {
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope);
const [modal, setModal] = useAtom(tableModalAtom, globalScope);
const open = modal === "cloudLogs";
const setOpen = (open: boolean) => setModal(open ? "cloudLogs" : null);
return (
<>
<TableToolbarButton
title="Cloud logs"
icon={<LogsIcon />}
onClick={
projectSettings.rowyRunUrl
? () => setOpen(true)
: () => openRowyRunModal({ feature: "Cloud logs" })
}
/>
{open && (
<CloudLogsModal onClose={() => setOpen(false)} title="Cloud logs" />
)}
</>
);
}

View File

@@ -0,0 +1,346 @@
import useSWR from "swr";
import { useAtom } from "jotai";
import { startCase } from "lodash-es";
import {
LinearProgress,
ToggleButtonGroup,
ToggleButton,
Stack,
Typography,
TextField,
InputAdornment,
Button,
} from "@mui/material";
import RefreshIcon from "@mui/icons-material/Refresh";
import LogsIcon from "@src/assets/icons/CloudLogs";
import Modal, { IModalProps } from "@src/components/Modal";
import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton";
import MultiSelect from "@rowy/multiselect";
import TimeRangeSelect from "./TimeRangeSelect";
import CloudLogList from "./CloudLogList";
import BuildLogs from "./BuildLogs";
import EmptyState from "@src/components/EmptyState";
import CloudLogSeverityIcon, { SEVERITY_LEVELS } from "./CloudLogSeverityIcon";
import {
globalScope,
projectIdAtom,
rowyRunAtom,
compatibleRowyRunVersionAtom,
} from "@src/atoms/globalScope";
import {
tableScope,
tableSettingsAtom,
tableSchemaAtom,
} from "@src/atoms/tableScope";
import { cloudLogFiltersAtom, cloudLogFetcher } from "./utils";
export default function CloudLogsModal(props: IModalProps) {
const [projectId] = useAtom(projectIdAtom, globalScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
const [compatibleRowyRunVersion] = useAtom(
compatibleRowyRunVersionAtom,
globalScope
);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [cloudLogFilters, setCloudLogFilters] = useAtom(
cloudLogFiltersAtom,
globalScope
);
const { data, mutate, isValidating } = useSWR(
cloudLogFilters.type === "build"
? null
: [
"/logs",
rowyRun,
projectId,
cloudLogFilters,
tableSettings.collection || "",
],
cloudLogFetcher,
{
fallbackData: [],
revalidateOnMount: true,
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
return (
<Modal
{...props}
maxWidth="xl"
fullWidth
fullHeight
ScrollableDialogContentProps={{ disableBottomDivider: true }}
header={
<>
<Stack
direction="row"
spacing={2}
justifyContent="space-between"
alignItems="center"
sx={{
mt: { md: "calc(var(--dialog-title-height) * -1)" },
"&, & .MuiTab-root": {
minHeight: { md: "var(--dialog-title-height)" },
},
ml: { md: 18 },
mr: { md: 40 / 8 + 3 },
minHeight: 32,
boxSizing: "content-box",
overflowX: "auto",
overflowY: "hidden",
py: 0,
px: { xs: "var(--dialog-spacing)", md: 0 },
pb: { xs: 1.5, md: 0 },
"& > *": { flexShrink: 0 },
}}
>
{compatibleRowyRunVersion!({ minVersion: "1.2.0" }) ? (
<ToggleButtonGroup
value={cloudLogFilters.type}
exclusive
onChange={(_, v) =>
setCloudLogFilters((c) => ({
type: v,
timeRange: c.timeRange,
}))
}
aria-label="Filter by log type"
>
<ToggleButton value="webhook">Webhooks</ToggleButton>
<ToggleButton value="functions">Functions</ToggleButton>
<ToggleButton value="audit">Audit</ToggleButton>
<ToggleButton value="build">Build</ToggleButton>
</ToggleButtonGroup>
) : (
<ToggleButtonGroup
value={cloudLogFilters.type}
exclusive
onChange={(_, v) =>
setCloudLogFilters((c) => ({
type: v,
timeRange: c.timeRange,
}))
}
aria-label="Filter by log type"
>
<ToggleButton value="build">Build</ToggleButton>
</ToggleButtonGroup>
)}
{cloudLogFilters.type === "webhook" && (
<MultiSelect
multiple
label="Webhook:"
labelPlural="webhooks"
options={
Array.isArray(tableSchema.webhooks)
? tableSchema.webhooks.map((x) => ({
label: x.name,
value: x.endpoint,
}))
: []
}
value={cloudLogFilters.webhook ?? []}
onChange={(v) =>
setCloudLogFilters((prev) => ({ ...prev, webhook: v }))
}
TextFieldProps={{
id: "webhook",
className: "labelHorizontal",
sx: { "& .MuiInputBase-root": { width: 180 } },
fullWidth: false,
}}
itemRenderer={(option) => (
<>
{option.label}&nbsp;<code>{option.value}</code>
</>
)}
/>
)}
{cloudLogFilters.type === "audit" && (
<TextField
id="auditRowId"
label="Row ID:"
value={cloudLogFilters.auditRowId}
onChange={(e) =>
setCloudLogFilters((prev) => ({
...prev,
auditRowId: e.target.value,
}))
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{tableSettings.collection}/
</InputAdornment>
),
}}
className="labelHorizontal"
sx={{
"& .MuiInputBase-root, & .MuiInputBase-input": {
typography: "body2",
fontFamily: "mono",
},
"& .MuiInputAdornment-positionStart": {
m: "0 !important",
pointerEvents: "none",
},
"& .MuiInputBase-input": { pl: 0 },
}}
/>
)}
{/* Spacer */}
<div style={{ flexGrow: 1 }} />
{cloudLogFilters.type !== "build" && (
<>
{!isValidating && Array.isArray(data) && (
<Typography
variant="body2"
color="text.disabled"
display="block"
style={{ userSelect: "none" }}
>
{data.length} entries
</Typography>
)}
<MultiSelect
aria-label="Severity"
labelPlural="severity levels"
options={Object.keys(SEVERITY_LEVELS)}
value={cloudLogFilters.severity ?? []}
onChange={(severity) =>
setCloudLogFilters((prev) => ({ ...prev, severity }))
}
TextFieldProps={{
style: { width: 130 },
placeholder: "Severity",
SelectProps: {
renderValue: () => {
if (
!Array.isArray(cloudLogFilters.severity) ||
cloudLogFilters.severity.length === 0
)
return `Severity`;
if (cloudLogFilters.severity.length === 1)
return (
<>
Severity{" "}
<CloudLogSeverityIcon
severity={cloudLogFilters.severity[0]}
style={{ marginTop: -2, marginBottom: -7 }}
/>
</>
);
return `Severity (${cloudLogFilters.severity.length})`;
},
},
}}
itemRenderer={(option) => (
<>
<CloudLogSeverityIcon
severity={option.value}
sx={{ mr: 1 }}
/>
{startCase(option.value.toLowerCase())}
</>
)}
/>
<TimeRangeSelect
aria-label="Time range"
value={cloudLogFilters.timeRange}
onChange={(value) =>
setCloudLogFilters((c) => ({ ...c, timeRange: value }))
}
/>
<TableToolbarButton
onClick={() => mutate()}
title="Refresh"
icon={<RefreshIcon />}
disabled={isValidating}
/>
</>
)}
</Stack>
{isValidating && (
<LinearProgress
style={{
borderRadius: 0,
marginTop: -4,
marginBottom: -1,
minHeight: 4,
}}
/>
)}
{/* <code>{logQueryUrl}</code> */}
</>
}
>
{cloudLogFilters.type === "build" ? (
<BuildLogs />
) : Array.isArray(data) && data.length > 0 ? (
<>
<CloudLogList items={data} sx={{ mx: -1.5, mt: 1.5 }} />
{cloudLogFilters.timeRange.type !== "range" && (
<Button
style={{
marginLeft: "auto",
marginRight: "auto",
display: "flex",
}}
onClick={() =>
setCloudLogFilters((c) => ({
...c,
timeRange: {
...c.timeRange,
value: (c.timeRange as any).value * 2,
},
}))
}
>
Load more (last {cloudLogFilters.timeRange.value * 2}{" "}
{cloudLogFilters.timeRange.type})
</Button>
)}
</>
) : isValidating ? (
<EmptyState
Icon={LogsIcon}
message="Fetching logs…"
description={"\xa0"}
/>
) : (
<EmptyState
Icon={LogsIcon}
message="No logs"
description={
cloudLogFilters.type === "webhook" &&
(!Array.isArray(tableSchema.webhooks) ||
tableSchema.webhooks?.length === 0)
? "There are no webhooks in this table"
: cloudLogFilters.type === "audit" &&
tableSettings.audit === false
? "Auditing is disabled in this table"
: "\xa0"
}
/>
)}
</Modal>
);
}

View File

@@ -0,0 +1,96 @@
import {
TextField,
FilledTextFieldProps,
InputAdornment,
MenuItem,
} from "@mui/material";
import type { CloudLogFilters } from "./utils";
export interface ITimeRangeSelectProps
extends Partial<Omit<FilledTextFieldProps, "value" | "onChange">> {
value: CloudLogFilters["timeRange"];
onChange: (value: CloudLogFilters["timeRange"]) => void;
}
export default function TimeRangeSelect({
value,
onChange,
...props
}: ITimeRangeSelectProps) {
return (
<fieldset style={{ appearance: "none", padding: 0, border: 0 }}>
{value && value.type !== "range" && (
<TextField
aria-label={`Custom ${value.type} value`}
id="timeRangeSelect.value"
type="number"
value={value.value}
onChange={(e) =>
onChange({ type: value.type, value: Number(e.target.value) })
}
sx={{
mr: "-1px",
"& .MuiInputBase-root": {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
"& .MuiInputAdornment-positionStart": {
mt: "0 !important",
pointerEvents: "none",
mr: -0.75,
},
"& .MuiInputBase-input": {
width: "calc(3ch + 16px)",
pr: 0,
},
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">Last</InputAdornment>
),
}}
inputProps={{ min: 1 }}
/>
)}
<TextField
select
id="timeRangeSelect.type"
value={value?.type || "days"}
{...props}
sx={{
"& .MuiInputBase-root":
value?.type !== "range"
? {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}
: {},
"& .MuiInputBase-input": { minHeight: 20 },
...props.sx,
}}
onChange={(e) => {
const newValue: any = { type: e.target.value };
if (e.target.value === "seconds") newValue.value = 30;
else if (e.target.value === "minutes") newValue.value = 15;
else if (e.target.value === "hours") newValue.value = 3;
else if (e.target.value === "days") newValue.value = 7;
onChange(newValue);
}}
>
<MenuItem value="seconds">seconds</MenuItem>
<MenuItem value="minutes">minutes</MenuItem>
<MenuItem value="hours">hours</MenuItem>
<MenuItem value="days">days</MenuItem>
{/* <MenuItem value="range">Custom range…</MenuItem> */}
</TextField>
</fieldset>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./CloudLogs";
export { default } from "./CloudLogs";

View File

@@ -0,0 +1,104 @@
import { atomWithHash } from "jotai/utils";
import { sub } from "date-fns";
import { rowyRunAtom } from "@src/atoms/globalScope";
import { SEVERITY_LEVELS } from "./CloudLogSeverityIcon";
export type CloudLogFilters = {
type: "webhook" | "functions" | "audit" | "build";
timeRange:
| { type: "seconds" | "minutes" | "hours" | "days"; value: number }
| { type: "range"; start: Date; end: Date };
severity?: Array<keyof typeof SEVERITY_LEVELS>;
webhook?: string[];
auditRowId?: string;
buildLogExpanded?: number;
};
export const cloudLogFiltersAtom = atomWithHash<CloudLogFilters>(
"cloudLogFilters",
{
type: "build",
timeRange: { type: "days", value: 7 },
}
);
export const cloudLogFetcher = (
endpointRoot: string,
rowyRun: ReturnType<typeof rowyRunAtom["read"]>,
projectId: string,
cloudLogFilters: CloudLogFilters,
tablePath: string
) => {
// https://cloud.google.com/logging/docs/view/logging-query-language
let logQuery: string[] = [];
switch (cloudLogFilters.type) {
case "webhook":
logQuery.push(
`logName = "projects/${projectId}/logs/rowy-webhook-events"`
);
logQuery.push(`jsonPayload.url : "${tablePath}"`);
if (
Array.isArray(cloudLogFilters.webhook) &&
cloudLogFilters.webhook.length > 0
)
logQuery.push(
cloudLogFilters.webhook
.map((id) => `jsonPayload.url : "${id}"`)
.join(encodeURIComponent(" OR "))
);
break;
case "audit":
logQuery.push(`logName = "projects/${projectId}/logs/rowy-audit"`);
logQuery.push(`jsonPayload.ref.collectionPath = "${tablePath}"`);
if (cloudLogFilters.auditRowId)
logQuery.push(
`jsonPayload.ref.rowId = "${cloudLogFilters.auditRowId}"`
);
break;
case "functions":
logQuery.push(`resource.labels.function_name = "R-${tablePath}"`);
break;
default:
break;
}
if (cloudLogFilters.timeRange.type === "range") {
} else {
try {
const minDate = sub(new Date(), {
[cloudLogFilters.timeRange.type]: cloudLogFilters.timeRange.value,
});
logQuery.push(`timestamp >= "${minDate.toISOString()}"`);
} catch (e) {
console.error("Failed to calculate minimum date", e);
}
}
if (
Array.isArray(cloudLogFilters.severity) &&
cloudLogFilters.severity.length > 0
) {
logQuery.push(`severity = (${cloudLogFilters.severity.join(" OR ")})`);
}
const logQueryUrl =
endpointRoot +
(logQuery.length > 0
? `?filter=${logQuery
.map((item) => `(${item})`)
.join(encodeURIComponent("\n"))}`
: "");
if (rowyRun) {
return rowyRun({
route: { path: logQueryUrl, method: "GET" },
});
}
return [];
};

View File

@@ -1,7 +1,7 @@
import { useAtom } from "jotai";
import MultiSelect, { MultiSelectProps } from "@rowy/multiselect";
import { Stack, Typography } from "@mui/material";
import { Stack, StackProps, Typography } from "@mui/material";
import { globalScope, altPressAtom } from "@src/atoms/globalScope";
import { tableScope, tableColumnsOrderedAtom } from "@src/atoms/tableScope";
@@ -18,11 +18,13 @@ export type ColumnOption = {
export interface IColumnSelectProps {
filterColumns?: (column: ColumnConfig) => boolean;
showFieldNames?: boolean;
options?: ColumnOption[];
}
export default function ColumnSelect({
filterColumns,
showFieldNames,
...props
}: IColumnSelectProps & Omit<MultiSelectProps<string>, "options">) {
const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope);
@@ -30,7 +32,7 @@ export default function ColumnSelect({
filterColumns
? tableColumnsOrdered.filter(filterColumns)
: tableColumnsOrdered
).map(({ key, name, type, index, fixed }) => ({
).map(({ key, name, type, index }) => ({
value: key,
label: name,
type,
@@ -43,15 +45,49 @@ export default function ColumnSelect({
label="Column"
labelPlural="columns"
{...(props as any)}
itemRenderer={(option: ColumnOption) => <ColumnItem option={option} />}
itemRenderer={(option: ColumnOption) => (
<ColumnItem option={option} showFieldNames={showFieldNames} />
)}
TextFieldProps={{
...props.TextFieldProps,
SelectProps: {
...props.TextFieldProps?.SelectProps,
renderValue: () => {
if (Array.isArray(props.value) && props.value.length > 1)
return `${props.value.length} columns`;
const value = Array.isArray(props.value)
? props.value[0]
: props.value;
const option = options.find((o) => o.value === value);
return option ? (
<ColumnItem
option={option}
showFieldNames={showFieldNames}
sx={{ "& .MuiSvgIcon-root": { my: -0.25 } }}
/>
) : (
value
);
},
},
}}
/>
);
}
export interface IColumnItemProps extends Partial<StackProps> {
option: ColumnOption;
showFieldNames?: boolean;
children?: React.ReactNode;
}
export function ColumnItem({
option,
showFieldNames,
children,
}: React.PropsWithChildren<{ option: ColumnOption }>) {
...props
}: IColumnItemProps) {
const [altPress] = useAtom(altPressAtom, globalScope);
return (
@@ -59,13 +95,17 @@ export function ColumnItem({
direction="row"
alignItems="center"
gap={1}
sx={{ color: "text.secondary", width: "100%" }}
{...props}
sx={[
{ color: "text.secondary", width: "100%" },
...(Array.isArray(props.sx) ? props.sx : props.sx ? [props.sx] : []),
]}
>
{getFieldProp("icon", option.type)}
<Typography color="text.primary" style={{ flexGrow: 1 }}>
{altPress ? <code>{option.value}</code> : option.label}
</Typography>
{altPress && (
{altPress ? (
<Typography
color="text.disabled"
variant="caption"
@@ -73,7 +113,11 @@ export function ColumnItem({
>
{option.index}
</Typography>
)}
) : showFieldNames ? (
<Typography color="text.primary">
<code>{option.value}</code>
</Typography>
) : null}
{children}
</Stack>
);

View File

@@ -0,0 +1,83 @@
import { useRef, useState } from "react";
import {
Button,
ButtonProps,
Menu,
MenuItem,
Divider,
ListItemIcon,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EmailIcon from "@mui/icons-material/EmailOutlined";
import { extensionTypes, extensionNames, ExtensionType } from "./utils";
import { EMAIL_REQUEST } from "@src/constants/externalLinks";
export interface IAddExtensionButtonProps extends Partial<ButtonProps> {
handleAddExtension: (type: ExtensionType) => void;
}
export default function AddExtensionButton({
handleAddExtension,
...props
}: IAddExtensionButtonProps) {
const addButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const handleChooseAddType = (type: ExtensionType) => {
setOpen(false);
handleAddExtension(type);
};
return (
<>
<Button
color="primary"
startIcon={<AddIcon />}
onClick={() => setOpen(true)}
ref={addButtonRef}
sx={{
alignSelf: { sm: "flex-end" },
mt: {
sm: `calc(var(--dialog-title-height) * -1 + (var(--dialog-title-height) - 32px) / 2)`,
},
mx: { xs: "var(--dialog-spacing)", sm: undefined },
mr: { sm: 8 },
mb: { xs: 1.5, sm: 2 },
}}
{...props}
>
Add Extension
</Button>
<Menu
anchorEl={addButtonRef.current}
open={open}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
{extensionTypes.map((type) => (
<MenuItem onClick={() => handleChooseAddType(type)}>
{extensionNames[type]}
</MenuItem>
))}
<Divider variant="middle" />
<MenuItem
component="a"
href={EMAIL_REQUEST}
target="_blank"
rel="noopener noreferrer"
>
<ListItemIcon>
<EmailIcon aria-label="Send email" sx={{ mr: 1.5 }} />
</ListItemIcon>
Request new Extension
</MenuItem>
</Menu>
</>
);
}

View File

@@ -0,0 +1,156 @@
import { format, formatRelative } from "date-fns";
import {
Stack,
List,
ListItem,
ListItemText,
Avatar,
IconButton,
Switch,
Tooltip,
Typography,
} from "@mui/material";
import ExtensionIcon from "@src/assets/icons/Extension";
import DuplicateIcon from "@src/assets/icons/Copy";
import EditIcon from "@mui/icons-material/EditOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EmptyState from "@src/components/EmptyState";
import { extensionNames, IExtension } from "./utils";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
export interface IExtensionListProps {
extensions: IExtension[];
handleUpdateActive: (index: number, active: boolean) => void;
handleDuplicate: (index: number) => void;
handleEdit: (index: number) => void;
handleDelete: (index: number) => void;
}
export default function ExtensionList({
extensions,
handleUpdateActive,
handleDuplicate,
handleEdit,
handleDelete,
}: IExtensionListProps) {
if (extensions.length === 0)
return (
<EmptyState
message="Add your first extension above"
description="Your extensions will appear here"
Icon={ExtensionIcon}
style={{ height: 89 * 3 - 1 }}
/>
);
return (
<List style={{ minHeight: 89 * 3 - 1 }} disablePadding>
{extensions.map((extensionObject, index) => (
<ListItem
disableGutters
dense={false}
divider={index !== extensions.length - 1}
children={
<ListItemText
primary={extensionObject.name}
secondary={extensionNames[extensionObject.type]}
primaryTypographyProps={{
style: {
minHeight: 40,
display: "flex",
alignItems: "center",
},
}}
/>
}
secondaryAction={
<Stack alignItems="flex-end">
<Stack direction="row" alignItems="center" spacing={1}>
<Tooltip
title={extensionObject.active ? "Deactivate" : "Activate"}
>
<Switch
checked={extensionObject.active}
onClick={() =>
handleUpdateActive(index, !extensionObject.active)
}
inputProps={{ "aria-label": "Activate" }}
sx={{ mr: 1 }}
/>
</Tooltip>
<Tooltip title="Duplicate">
<IconButton
aria-label="Duplicate"
onClick={() => handleDuplicate(index)}
>
<DuplicateIcon />
</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 {extensionObject.lastEditor.displayName}
<br />
at{" "}
{format(
extensionObject.lastEditor.lastUpdate,
DATE_TIME_FORMAT
)}
</>
}
>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" sx={{ color: "text.disabled" }}>
{formatRelative(
extensionObject.lastEditor.lastUpdate,
new Date()
)}
</Typography>
<Avatar
alt={`${extensionObject.lastEditor.displayName}s profile photo`}
src={extensionObject.lastEditor.photoURL}
sx={{ width: 24, height: 24, "&&": { mr: -0.5 } }}
/>
</Stack>
</Tooltip>
</Stack>
}
sx={{
flexWrap: { xs: "wrap", sm: "nowrap" },
"& .MuiListItemSecondaryAction-root": {
position: { xs: "static", sm: "absolute" },
width: { xs: "100%", sm: "auto" },
transform: { xs: "none", sm: "translateY(-50%)" },
},
pr: { xs: 0, sm: 216 / 8 },
}}
/>
))}
</List>
);
}

View File

@@ -0,0 +1,158 @@
import { useState } from "react";
import { useAtom } from "jotai";
import { deleteField } from "firebase/firestore";
import { Button, Link, Typography } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import DownloadIcon from "@mui/icons-material/FileDownloadOutlined";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import GoIcon from "@mui/icons-material/ChevronRight";
import Modal from "@src/components/Modal";
import { globalScope, currentUserAtom } from "@src/atoms/globalScope";
import {
tableScope,
tableSettingsAtom,
tableSchemaAtom,
updateTableSchemaAtom,
} from "@src/atoms/tableScope";
import { sparkToExtensionObjects } from "./utils";
import { WIKI_LINKS } from "@src/constants/externalLinks";
export interface IExtensionMigrationProps {
handleClose: () => void;
handleUpgradeComplete: () => void;
}
export default function ExtensionMigration({
handleClose,
handleUpgradeComplete,
}: IExtensionMigrationProps) {
const [currentUser] = useAtom(currentUserAtom, globalScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
const [isSaved, setIsSaved] = useState(false);
const [isUpgrading, setIsUpgrading] = useState(false);
const currentEditor = () => ({
displayName: currentUser?.displayName ?? "Unknown user",
photoURL: currentUser?.photoURL ?? "",
lastUpdate: Date.now(),
});
const downloadSparkFile = () => {
const tablePathTokens =
tableSettings.collection.split("/").filter(function (_, i) {
// replace IDs with dash that appears at even indexes
return i % 2 === 0;
}) ?? [];
const tablePath = tablePathTokens.join("-");
// https://medium.com/front-end-weekly/text-file-download-in-react-a8b28a580c0d
const element = document.createElement("a");
const file = new Blob([tableSchema.sparks ?? ""], {
type: "text/plain;charset=utf-8",
});
element.href = URL.createObjectURL(file);
element.download = `sparks-${tablePath}.ts`;
document.body.appendChild(element);
element.click();
setIsSaved(true);
};
const upgradeToExtensions = async () => {
setIsUpgrading(true);
const extensionObjects = sparkToExtensionObjects(
tableSchema.sparks ?? "[]",
currentEditor()
);
console.log(extensionObjects);
if (updateTableSchema)
await updateTableSchema({
extensionObjects,
sparks: deleteField() as any,
});
setTimeout(handleUpgradeComplete, 500);
};
return (
<Modal
onClose={handleClose}
maxWidth="xs"
fullWidth
disableBackdropClick
disableEscapeKeyDown
title="Welcome to Extensions"
children={
<>
<div>
<Typography paragraph>
It looks like you have Sparks configured for this table.
</Typography>
<Typography>
Sparks have been revamped to Extensions, with a brand new UI. Your
existing Sparks are not compatible with this change, but you can
migrate your Sparks to Extensions.
</Typography>
</div>
<div>
<Typography variant="subtitle1" component="h3" gutterBottom>
1. Back up existing Sparks
</Typography>
<Typography paragraph>
Back up your existing Sparks to a .ts file.
</Typography>
<Button
variant={isSaved ? "outlined" : "contained"}
color={isSaved ? "secondary" : "primary"}
onClick={downloadSparkFile}
endIcon={<DownloadIcon />}
style={{ width: "100%" }}
>
Save Sparks
</Button>
</div>
<div>
<Typography variant="subtitle1" component="h3" gutterBottom>
2. Migrate Sparks to Extensions
</Typography>
<Typography gutterBottom>
After the upgrade, Sparks will be removed from this table. You may
need to make manual changes to your Extensions code.
</Typography>
<Link
href={WIKI_LINKS.extensions}
target="_blank"
rel="noopener noreferrer"
paragraph
display="block"
>
Read the Extensions documentation
<InlineOpenInNewIcon />
</Link>
<LoadingButton
variant="contained"
color="primary"
loading={isUpgrading}
loadingPosition="end"
onClick={upgradeToExtensions}
disabled={!isSaved || isUpgrading}
endIcon={<GoIcon />}
style={{ width: "100%" }}
>
Migrate to Extensions
</LoadingButton>
</div>
</>
}
/>
);
}

View File

@@ -0,0 +1,189 @@
import { useState } from "react";
import { useSetAtom } from "jotai";
import { isEqual } from "lodash-es";
import useStateRef from "react-usestateref";
import { Grid, TextField, FormControlLabel, Switch } from "@mui/material";
import Modal, { IModalProps } from "@src/components/Modal";
import SteppedAccordion from "@src/components/SteppedAccordion";
import Step1Triggers from "./Step1Triggers";
import Step2RequiredFields from "./Step2RequiredFields";
import Step3Conditions from "./Step3Conditions";
import Step4Body from "./Step4Body";
import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope";
import { extensionNames, IExtension } from "./utils";
type StepValidation = Record<"condition" | "extensionBody", boolean>;
export interface IExtensionModalStepProps {
extensionObject: IExtension;
setExtensionObject: React.Dispatch<React.SetStateAction<IExtension>>;
validation: StepValidation;
setValidation: React.Dispatch<React.SetStateAction<StepValidation>>;
validationRef: React.RefObject<StepValidation>;
}
export interface IExtensionModalProps {
handleClose: IModalProps["onClose"];
handleAdd: (extensionObject: IExtension) => void;
handleUpdate: (extensionObject: IExtension) => void;
mode: "add" | "update";
extensionObject: IExtension;
}
export default function ExtensionModal({
handleClose,
handleAdd,
handleUpdate,
mode,
extensionObject: initialObject,
}: IExtensionModalProps) {
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const [extensionObject, setExtensionObject] =
useState<IExtension>(initialObject);
const [validation, setValidation, validationRef] =
useStateRef<StepValidation>({ condition: true, extensionBody: true });
const edited = !isEqual(initialObject, extensionObject);
const handleAddOrUpdate = () => {
if (mode === "add") handleAdd(extensionObject);
if (mode === "update") handleUpdate(extensionObject);
};
const stepProps = {
extensionObject,
setExtensionObject,
validation,
setValidation,
validationRef,
};
return (
<Modal
onClose={handleClose}
disableBackdropClick
disableEscapeKeyDown
fullWidth
title={`${mode === "add" ? "Add" : "Update"} Extension: ${
extensionNames[extensionObject.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="Extension name"
variant="filled"
fullWidth
autoFocus
value={extensionObject.name}
error={edited && !extensionObject.name.length}
helperText={
edited && !extensionObject.name.length ? "Required" : " "
}
onChange={(event) => {
setExtensionObject({
...extensionObject,
name: event.target.value,
});
}}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
checked={extensionObject.active}
onChange={(e) =>
setExtensionObject((extensionObject) => ({
...extensionObject,
active: e.target.checked,
}))
}
size="medium"
/>
}
label={`Extension is ${
!extensionObject.active ? "de" : ""
}activated`}
/>
</Grid>
</Grid>
<SteppedAccordion
steps={[
{
id: "triggers",
title: "Trigger events",
content: <Step1Triggers {...stepProps} />,
},
{
id: "requiredFields",
title: "Required fields",
optional: true,
content: <Step2RequiredFields {...stepProps} />,
},
{
id: "conditions",
title: "Trigger conditions",
optional: true,
content: <Step3Conditions {...stepProps} />,
},
{
id: "body",
title: "Extension body",
content: <Step4Body {...stepProps} />,
},
]}
/>
</>
}
actions={{
primary: {
children: mode === "add" ? "Add" : "Update",
disabled: !edited || !extensionObject.name.length,
onClick: () => {
let warningMessage;
if (!validation.condition && !validation.extensionBody) {
warningMessage = "Condition and Extension body are not valid";
} else if (!validation.condition) {
warningMessage = "Condition is not valid";
} else if (!validation.extensionBody) {
warningMessage = "Extension body is not valid";
}
if (warningMessage) {
confirm({
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,287 @@
import { useState } from "react";
import { useAtom, useSetAtom } from "jotai";
import { isEqual } from "lodash-es";
import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton";
import ExtensionIcon from "@src/assets/icons/Extension";
import Modal from "@src/components/Modal";
import AddExtensionButton from "./AddExtensionButton";
import ExtensionList from "./ExtensionList";
import ExtensionModal from "./ExtensionModal";
import ExtensionMigration from "./ExtensionMigration";
import {
globalScope,
currentUserAtom,
projectSettingsAtom,
rowyRunAtom,
rowyRunModalAtom,
confirmDialogAtom,
tableModalAtom,
} from "@src/atoms/globalScope";
import {
tableScope,
tableSettingsAtom,
tableSchemaAtom,
updateTableSchemaAtom,
} from "@src/atoms/tableScope";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { emptyExtensionObject, IExtension, ExtensionType } from "./utils";
import { runRoutes } from "@src/constants/runRoutes";
import { analytics, logEvent } from "@src/analytics";
import { getTableSchemaPath } from "@src/utils/table";
export default function Extensions() {
const [currentUser] = useAtom(currentUserAtom, globalScope);
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope);
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const [modal, setModal] = useAtom(tableModalAtom, globalScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
const currentExtensionObjects = (tableSchema.extensionObjects ??
[]) as IExtension[];
const [localExtensionsObjects, setLocalExtensionsObjects] = useState(
currentExtensionObjects
);
const open = modal === "extensions";
const setOpen = (open: boolean) => setModal(open ? "extensions" : null);
const [openMigrationGuide, setOpenMigrationGuide] = useState(false);
const [extensionModal, setExtensionModal] = useState<{
mode: "add" | "update";
extensionObject: IExtension;
index?: number;
} | null>(null);
const snackLogContext = useSnackLogContext();
const edited = !isEqual(currentExtensionObjects, localExtensionsObjects);
if (!projectSettings.rowyRunUrl)
return (
<TableToolbarButton
title="Extensions"
onClick={() => openRowyRunModal({ feature: "Extensions" })}
icon={<ExtensionIcon />}
/>
);
const handleOpen = () => {
if (tableSchema.sparks) {
// migration is required
console.log("Extension migration required.");
setOpenMigrationGuide(true);
} else {
setOpen(true);
}
};
const handleClose = (
_setOpen: React.Dispatch<React.SetStateAction<boolean>>
) => {
if (edited) {
_setOpen(true);
confirm({
title: "Discard changes?",
confirm: "Discard",
handleConfirm: () => {
_setOpen(false);
setLocalExtensionsObjects(currentExtensionObjects);
setOpen(false);
},
});
} else {
setOpen(false);
}
};
const handleSaveExtensions = async (callback?: Function) => {
if (updateTableSchema)
await updateTableSchema({ extensionObjects: localExtensionsObjects });
if (callback) callback();
setOpen(false);
};
const handleSaveDeploy = async () => {
handleSaveExtensions(() => {
try {
if (rowyRun) {
snackLogContext.requestSnackLog();
rowyRun({
route: runRoutes.buildFunction,
body: {
tablePath: tableSettings.collection,
pathname: window.location.pathname,
tableConfigPath: getTableSchemaPath(tableSettings),
},
});
logEvent(analytics, "deployed_extensions");
}
} catch (e) {
console.error(e);
}
});
};
const handleAddExtension = (extensionObject: IExtension) => {
setLocalExtensionsObjects([...localExtensionsObjects, extensionObject]);
logEvent(analytics, "created_extension", { type: extensionObject.type });
setExtensionModal(null);
};
const handleUpdateExtension = (extensionObject: IExtension) => {
setLocalExtensionsObjects(
localExtensionsObjects.map((extension, index) => {
if (index === extensionModal?.index) {
return {
...extensionObject,
lastEditor: currentEditor(),
};
} else {
return extension;
}
})
);
logEvent(analytics, "updated_extension", { type: extensionObject.type });
setExtensionModal(null);
};
const handleUpdateActive = (index: number, active: boolean) => {
setLocalExtensionsObjects(
localExtensionsObjects.map((extensionObject, i) => {
if (i === index) {
return {
...extensionObject,
active,
lastEditor: currentEditor(),
};
} else {
return extensionObject;
}
})
);
};
const handleDuplicate = (index: number) => {
setLocalExtensionsObjects([
...localExtensionsObjects,
{
...localExtensionsObjects[index],
name: `${localExtensionsObjects[index].name} (duplicate)`,
active: false,
lastEditor: currentEditor(),
},
]);
logEvent(analytics, "duplicated_extension", {
type: localExtensionsObjects[index].type,
});
};
const handleEdit = (index: number) => {
setExtensionModal({
mode: "update",
extensionObject: localExtensionsObjects[index],
index,
});
};
const handleDelete = (index: number) => {
confirm({
title: `Delete “${localExtensionsObjects[index].name}”?`,
body: "This Extension will be permanently deleted when you save",
confirm: "Confirm",
handleConfirm: () => {
setLocalExtensionsObjects(
localExtensionsObjects.filter((_, i) => i !== index)
);
},
});
};
const currentEditor = () => ({
displayName: currentUser?.displayName ?? "Unknown user",
photoURL: currentUser?.photoURL ?? "",
lastUpdate: Date.now(),
});
const activeExtensionCount = localExtensionsObjects.filter(
(extension) => extension.active
).length;
return (
<>
<TableToolbarButton
title="Extensions"
onClick={handleOpen}
icon={<ExtensionIcon />}
/>
{open && (
<Modal
onClose={handleClose}
disableBackdropClick={edited}
disableEscapeKeyDown={edited}
maxWidth="sm"
fullWidth
title={`Extensions (${activeExtensionCount}\u2009/\u2009${localExtensionsObjects.length})`}
header={
<AddExtensionButton
handleAddExtension={(type: ExtensionType) => {
setExtensionModal({
mode: "add",
extensionObject: emptyExtensionObject(type, currentEditor()),
});
}}
variant={
localExtensionsObjects.length === 0 ? "contained" : "outlined"
}
/>
}
children={
<ExtensionList
extensions={localExtensionsObjects}
handleUpdateActive={handleUpdateActive}
handleEdit={handleEdit}
handleDuplicate={handleDuplicate}
handleDelete={handleDelete}
/>
}
actions={{
primary: {
children: "Save & Deploy",
onClick: handleSaveDeploy,
disabled: !edited,
},
secondary: {
children: "Save",
onClick: () => handleSaveExtensions(),
disabled: !edited,
},
}}
/>
)}
{extensionModal && (
<ExtensionModal
handleClose={() => setExtensionModal(null)}
handleAdd={handleAddExtension}
handleUpdate={handleUpdateExtension}
mode={extensionModal.mode}
extensionObject={extensionModal.extensionObject}
/>
)}
{openMigrationGuide && (
<ExtensionMigration
handleClose={() => setOpenMigrationGuide(false)}
handleUpgradeComplete={() => {
setOpenMigrationGuide(false);
setOpen(true);
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,113 @@
import { useAtom } from "jotai";
import { IExtensionModalStepProps } from "./ExtensionModal";
import {
Typography,
FormControl,
FormLabel,
FormGroup,
FormControlLabel,
FormHelperText,
Checkbox,
} from "@mui/material";
import MultiSelect from "@rowy/multiselect";
import ColumnSelect from "@src/components/TableToolbar/ColumnSelect";
import {
globalScope,
compatibleRowyRunVersionAtom,
} from "@src/atoms/globalScope";
import { FieldType } from "@src/constants/fields";
import { triggerTypes } from "./utils";
export default function Step1Triggers({
extensionObject,
setExtensionObject,
}: IExtensionModalStepProps) {
const [compatibleRowyRunVersion] = useAtom(
compatibleRowyRunVersionAtom,
globalScope
);
return (
<>
<Typography gutterBottom>
Select which events trigger this extension
</Typography>
<FormControl component="fieldset" required>
<FormLabel component="legend" className="visually-hidden">
Triggers
</FormLabel>
<FormGroup>
{triggerTypes.map((trigger) => (
<>
<FormControlLabel
key={trigger}
label={trigger}
control={
<Checkbox
checked={extensionObject.triggers.includes(trigger)}
name={trigger}
onChange={() => {
setExtensionObject((extensionObject) => {
if (extensionObject.triggers.includes(trigger)) {
return {
...extensionObject,
triggers: extensionObject.triggers.filter(
(t) => t !== trigger
),
};
} else {
return {
...extensionObject,
triggers: [...extensionObject.triggers, trigger],
};
}
});
}}
/>
}
/>
{trigger === "update" &&
extensionObject.triggers.includes("update") &&
compatibleRowyRunVersion!({ minVersion: "1.2.4" }) && (
<ColumnSelect
multiple={true}
label="Tracked fields (optional)"
filterColumns={(column) =>
column.type !== FieldType.subTable
}
showFieldNames
value={extensionObject.trackedFields ?? []}
onChange={(trackedFields: string[]) => {
setExtensionObject((extensionObject) => {
return {
...extensionObject,
trackedFields,
};
});
}}
TextFieldProps={{
helperText: (
<>
<FormHelperText error={false} style={{ margin: 0 }}>
Only Changes to these fields will trigger the
extension. If left blank, any update will trigger
the extension.
</FormHelperText>
</>
),
FormHelperTextProps: { component: "div" } as any,
required: false,
}}
/>
)}
</>
))}
</FormGroup>
</FormControl>
</>
);
}

View File

@@ -0,0 +1,39 @@
import { IExtensionModalStepProps } from "./ExtensionModal";
import { Typography } from "@mui/material";
import ColumnSelect from "@src/components/TableToolbar/ColumnSelect";
import { FieldType } from "@src/constants/fields";
export default function Step2RequiredFields({
extensionObject,
setExtensionObject,
}: IExtensionModalStepProps) {
return (
<>
<Typography gutterBottom>
Optionally, select fields that must have a value set for the extension
to be triggered for that row
</Typography>
<ColumnSelect
aria-label="Required fields"
label=" "
multiple
value={extensionObject.requiredFields}
filterColumns={(c) => c.type !== FieldType.id}
showFieldNames
onChange={(requiredFields: string[]) =>
setExtensionObject((e) => ({ ...e, requiredFields }))
}
TextFieldProps={{ autoFocus: true }}
freeText
AddButtonProps={{ children: "Add other field…" }}
AddDialogProps={{
title: "Add other field",
textFieldLabel: "Field key",
}}
/>
</>
);
}

View File

@@ -0,0 +1,85 @@
import { lazy, Suspense } from "react";
import { IExtensionModalStepProps } from "./ExtensionModal";
import useStateRef from "react-usestateref";
import { Typography } from "@mui/material";
import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton";
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
import { WIKI_LINKS } from "@src/constants/externalLinks";
const CodeEditor = lazy(
() =>
import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */)
);
const additionalVariables = [
{
key: "change",
description:
"you can pass in field name to change.before.get() or change.after.get() to get changes",
},
{
key: "triggerType",
description: "triggerType indicates the type of the extension invocation",
},
{
key: "fieldTypes",
description:
"fieldTypes is a map of all fields and its corresponding field type",
},
{
key: "extensionConfig",
description: "the configuration object of this extension",
},
];
const diagnosticsOptions = {
noSemanticValidation: false,
noSyntaxValidation: false,
noSuggestionDiagnostics: true,
};
export default function Step3Conditions({
extensionObject,
setExtensionObject,
setValidation,
validationRef,
}: IExtensionModalStepProps) {
const [, setConditionEditorActive, conditionEditorActiveRef] =
useStateRef(false);
return (
<>
<Typography gutterBottom>
Optionally, write a function that determines if the extension should be
triggered for a given row. Leave the function to always return{" "}
<code>true</code> if you do not want to write additional logic.
</Typography>
<Suspense fallback={<FieldSkeleton height={200} />}>
<CodeEditor
value={extensionObject.conditions}
minHeight={200}
onChange={(newValue) => {
setExtensionObject({
...extensionObject,
conditions: newValue || "",
});
}}
onValidStatusUpdate={({ isValid }) => {
if (!conditionEditorActiveRef.current) return;
setValidation({ ...validationRef.current!, condition: isValid });
}}
diagnosticsOptions={diagnosticsOptions}
onMount={() => setConditionEditorActive(true)}
onUnmount={() => setConditionEditorActive(false)}
/>
</Suspense>
<CodeEditorHelper
docLink={WIKI_LINKS.extensions}
additionalVariables={additionalVariables}
/>
</>
);
}

View File

@@ -0,0 +1,86 @@
import { lazy, Suspense } from "react";
import { IExtensionModalStepProps } from "./ExtensionModal";
import { upperFirst } from "lodash-es";
import useStateRef from "react-usestateref";
import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton";
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
import { WIKI_LINKS } from "@src/constants/externalLinks";
const CodeEditor = lazy(
() =>
import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */)
);
const additionalVariables = [
{
key: "change",
description:
"you can pass in field name to change.before.get() or change.after.get() to get changes",
},
{
key: "triggerType",
description: "triggerType indicates the type of the extension invocation",
},
{
key: "fieldTypes",
description:
"fieldTypes is a map of all fields and its corresponding field type",
},
{
key: "extensionConfig",
description: "the configuration object of this extension",
},
];
const diagnosticsOptions = {
noSemanticValidation: false,
noSyntaxValidation: false,
noSuggestionDiagnostics: true,
};
export default function Step4Body({
extensionObject,
setExtensionObject,
setValidation,
validationRef,
}: IExtensionModalStepProps) {
const [, setBodyEditorActive, bodyEditorActiveRef] = useStateRef(false);
return (
<>
<Suspense fallback={<FieldSkeleton height={200} />}>
<CodeEditor
value={extensionObject.extensionBody}
minHeight={400}
onChange={(newValue) => {
setExtensionObject({
...extensionObject,
extensionBody: newValue || "",
});
}}
onValidStatusUpdate={({ isValid }) => {
if (!bodyEditorActiveRef.current) return;
setValidation({
...validationRef.current!,
extensionBody: isValid,
});
}}
diagnosticsOptions={diagnosticsOptions}
onMount={() => setBodyEditorActive(true)}
onUnmount={() => setBodyEditorActive(false)}
/>
</Suspense>
<CodeEditorHelper
docLink={
WIKI_LINKS[
`extensions${upperFirst(extensionObject.type)}` as "extensions"
] || WIKI_LINKS.extensions
}
additionalVariables={additionalVariables}
/>
</>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./Extensions";
export { default } from "./Extensions";

View File

@@ -0,0 +1,255 @@
export const extensionTypes = [
"task",
"docSync",
"historySnapshot",
"algoliaIndex",
"meiliIndex",
"bigqueryIndex",
"slackMessage",
"sendgridEmail",
"apiCall",
"twilioMessage",
] as const;
export type ExtensionType = typeof extensionTypes[number];
export const extensionNames: Record<ExtensionType, string> = {
task: "Task",
docSync: "Doc Sync",
historySnapshot: "History Snapshot",
algoliaIndex: "Algolia Index",
meiliIndex: "MeiliSearch Index",
bigqueryIndex: "Big Query Index",
slackMessage: "Slack Message",
sendgridEmail: "SendGrid Email",
apiCall: "API Call",
twilioMessage: "Twilio Message",
};
export type ExtensionTrigger = "create" | "update" | "delete";
export interface IExtensionEditor {
displayName: string;
photoURL: string;
lastUpdate: number;
}
export interface IExtension {
// rowy meta fields
name: string;
active: boolean;
lastEditor: IExtensionEditor;
// build fields
triggers: ExtensionTrigger[];
type: ExtensionType;
requiredFields: string[];
extensionBody: string;
conditions: string;
trackedFields?: string[];
}
export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"];
const extensionBodyTemplate = {
task: `const extensionBody: TaskBody = async({row, db, change, ref}) => {
// task extensions are very flexible you can do anything from updating other documents in your database, to making an api request to 3rd party service.
// example:
// we can post notification to different discord channels based on row data
/*
const topic = row.topic;
const channel = await db.collection('discordChannels').doc(topic).get();
const channelUrl = await channel.get("channelUrl");
const content = "Hello discord channel";
return fetch("https://discord.com/api/webhooks/"+channelUrl, {
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
content
})
}).then(async resp => {
const result = await resp.json()
if (resp.ok) console.info(result)
else console.error(result)
})
*/
}`,
docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return ({
fieldsToSync: [], // a list of string of column names
row: row, // object of data to sync, usually the row itself
targetPath: "", // fill in the path here
})
}`,
historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return ({
trackedFields: [], // a list of string of column names
})
}`,
algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return ({
fieldsToSync: [], // a list of string of column names
row: row, // object of data to sync, usually the row itself
index: "", // algolia index to sync to
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
}`,
meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return({
fieldsToSync: [], // a list of string of column names
row: row, // object of data to sync, usually the row itself
index: "", // algolia index to sync to
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
}`,
bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return ({
fieldsToSync: [], // a list of string of column names
row: row, // object of data to sync, usually the row itself
index: "", // algolia index to sync to
objectID: ref.id, // algolia object ID, ref.id is one possible choice
})
}`,
slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return ({
channels: [], // a list of slack channel IDs in string
blocks: [], // the blocks parameter to pass in to slack api
text: "", // the text parameter to pass in to slack api
attachments: [], // the attachments parameter to pass in to slack api
})
}`,
sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return ({
from: "Name<example@domain.com>", // send from field
personalizations: [
{
to: [{ name: "", email: "" }], // recipient
dynamic_template_data: {
}, // template parameters
},
],
template_id: "", // sendgrid template ID
categories: [], // helper info to categorise sendgrid emails
custom_args:{
docPath:ref.path, // optional, reference to be used for tracking email events
// add any other custom args you want to pass to sendgrid events here
},
})
}`,
apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return ({
body: "",
url: "",
method: "",
callback: ()=>{},
})
}`,
twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref}) => {
// feel free to add your own code logic here
return ({
from:"",
to:"",
body:"Hi there!"
})
}`,
};
export function emptyExtensionObject(
type: ExtensionType,
user: IExtensionEditor
): IExtension {
return {
name: `${type} extension`,
active: false,
triggers: [],
type,
extensionBody: extensionBodyTemplate[type] ?? extensionBodyTemplate["task"],
requiredFields: [],
trackedFields: [],
conditions: `const condition: Condition = async({row, change}) => {
// feel free to add your own code logic here
return true;
}`,
lastEditor: user,
};
}
export function sparkToExtensionObjects(
sparkConfig: string,
user: IExtensionEditor
): IExtension[] {
const parseString2Array = (str: string): string[] => {
return str
.trim()
.replace(/\[|\]/g, "")
.split(",")
.map((x) => x.trim().replace(/'/g, ""));
};
const oldSparks = sparkConfig.replace(/"/g, "'");
const sparkTypes = [...oldSparks.matchAll(/type:(.*),/g)].map((x) =>
x[1].trim().replace(/'/g, "")
);
const triggers = [...oldSparks.matchAll(/triggers:(.*),/g)].map((x) =>
parseString2Array(x[1])
);
const shouldRun = [...oldSparks.matchAll(/shouldRun:(.*),/g)].map((x) =>
x[1].trim()
);
const requiredFields = [...oldSparks.matchAll(/requiredFields:(.*),/g)].map(
(x) => parseString2Array(x[1])
);
const splitSparks = oldSparks.split(`type:`);
const sparks = sparkTypes?.map((x, index) => {
const sparkBody = splitSparks[index + 1]
?.split("sparkBody:")[1]
?.trim()
.slice(0, -1);
const _triggers = triggers?.[index];
const _shouldRun = shouldRun?.[index];
const _requiredFields = requiredFields?.[index];
return {
type: x,
triggers: _triggers,
shouldRun: _shouldRun,
requiredFields: _requiredFields,
sparkBody,
};
});
const extensionObjects = sparks?.map((spark, index): IExtension => {
return {
// rowy meta fields
name: `Migrated spark ${index}`,
active: true,
lastEditor: user,
// ft build fields
triggers: (spark.triggers ?? []) as ExtensionTrigger[],
type: spark.type as ExtensionType,
requiredFields: spark.requiredFields ?? [],
extensionBody: spark.sparkBody,
conditions: spark.shouldRun ?? "",
};
});
return extensionObjects ?? [];
}

View File

@@ -9,7 +9,7 @@ import LoadedRowsStatus from "./LoadedRowsStatus";
import TableSettings from "./TableSettings";
import HiddenFields from "./HiddenFields";
import RowHeight from "./RowHeight";
// import BuildLogsSnack from "./CloudLogs/BuildLogs/BuildLogsSnack";
import BuildLogsSnack from "./CloudLogs/BuildLogs/BuildLogsSnack";
import { globalScope, userRolesAtom } from "@src/atoms/globalScope";
import {
@@ -18,7 +18,7 @@ import {
tableSchemaAtom,
} from "@src/atoms/tableScope";
import { FieldType } from "@src/constants/fields";
// import { useSnackLogContext } from "@src/contexts/SnackLogContext";
import { useSnackLogContext } from "@src/contexts/SnackLogContext";
// prettier-ignore
const Filters = lazy(() => import("./Filters" /* webpackChunkName: "Filters" */));
@@ -27,11 +27,11 @@ const Export = lazy(() => import("./Export" /* webpackChunkName: "Export" */));
// prettier-ignore
const ImportCsv = lazy(() => import("./ImportCsv" /* webpackChunkName: "ImportCsv" */));
// prettier-ignore
// const CloudLogs = lazy(() => import("./CloudLogs" /* webpackChunkName: "CloudLogs" */));
const CloudLogs = lazy(() => import("./CloudLogs" /* webpackChunkName: "CloudLogs" */));
// prettier-ignore
// const Extensions = lazy(() => import("./Extensions" /* webpackChunkName: "Extensions" */));
const Extensions = lazy(() => import("./Extensions" /* webpackChunkName: "Extensions" */));
// prettier-ignore
// const Webhooks = lazy(() => import("./Webhooks" /* webpackChunkName: "Webhooks" */));
const Webhooks = lazy(() => import("./Webhooks" /* webpackChunkName: "Webhooks" */));
// prettier-ignore
const ReExecute = lazy(() => import("./ReExecute" /* webpackChunkName: "ReExecute" */));
@@ -41,7 +41,7 @@ export default function TableToolbar() {
const [userRoles] = useAtom(userRolesAtom, globalScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
// const snackLogContext = useSnackLogContext();
const snackLogContext = useSnackLogContext();
const hasDerivatives =
Object.values(tableSchema.columns ?? {}).filter(
@@ -95,27 +95,23 @@ export default function TableToolbar() {
{userRoles.includes("ADMIN") && (
<>
{/* Spacer */} <div />
{/*
<Suspense fallback={<ButtonSkeleton/>}>
<Webhooks />
</Suspense>
*/}
{/*
<Suspense fallback={<ButtonSkeleton/>}>
<Extensions />
</Suspense>
*/}
{/*
<Suspense fallback={<ButtonSkeleton/>}>
<CloudLogs />
</Suspense>
*/}
{/* {snackLogContext.isSnackLogOpen && (
<BuildLogsSnack
onClose={snackLogContext.closeSnackLog}
onOpenPanel={alert}
/>
)} */}
<Suspense fallback={<ButtonSkeleton />}>
<Webhooks />
</Suspense>
<Suspense fallback={<ButtonSkeleton />}>
<Extensions />
</Suspense>
<Suspense fallback={<ButtonSkeleton />}>
<CloudLogs />
</Suspense>
{snackLogContext.isSnackLogOpen && (
<Suspense fallback={null}>
<BuildLogsSnack
onClose={snackLogContext.closeSnackLog}
onOpenPanel={alert}
/>
</Suspense>
)}
{(hasDerivatives || hasExtensions) && (
<Suspense fallback={<ButtonSkeleton />}>
<ReExecute />

View File

@@ -0,0 +1,83 @@
import { useRef, useState } from "react";
import {
Button,
ButtonProps,
Menu,
MenuItem,
Divider,
ListItemIcon,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EmailIcon from "@mui/icons-material/EmailOutlined";
import { webhookTypes, webhookNames, WebhookType } from "./utils";
import { EMAIL_REQUEST } from "@src/constants/externalLinks";
export interface IAddWebhookButtonProps extends Partial<ButtonProps> {
handleAddWebhook: (type: WebhookType) => void;
}
export default function AddWebhookButton({
handleAddWebhook,
...props
}: IAddWebhookButtonProps) {
const addButtonRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const handleChooseAddType = (type: WebhookType) => {
setOpen(false);
handleAddWebhook(type);
};
return (
<>
<Button
color="primary"
startIcon={<AddIcon />}
onClick={() => setOpen(true)}
ref={addButtonRef}
sx={{
alignSelf: { sm: "flex-end" },
mt: {
sm: `calc(var(--dialog-title-height) * -1 + (var(--dialog-title-height) - 32px) / 2)`,
},
mx: { xs: "var(--dialog-spacing)", sm: undefined },
mr: { sm: 8 },
mb: { xs: 1.5, sm: 2 },
}}
{...props}
>
Add webhook
</Button>
<Menu
anchorEl={addButtonRef.current}
open={open}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
{webhookTypes.map((type) => (
<MenuItem onClick={() => handleChooseAddType(type)}>
{webhookNames[type]}
</MenuItem>
))}
<Divider variant="middle" />
<MenuItem
component="a"
href={EMAIL_REQUEST}
target="_blank"
rel="noopener noreferrer"
>
<ListItemIcon>
<EmailIcon aria-label="Send email" sx={{ mr: 1.5 }} />
</ListItemIcon>
Request new webhook
</MenuItem>
</Menu>
</>
);
}

View File

@@ -0,0 +1,105 @@
import { Typography } from "@mui/material";
import WarningIcon from "@mui/icons-material/WarningAmber";
import { TableSettings } from "@src/types/table";
import { IWebhook } from "@src/components/TableToolbar/Webhooks/utils";
export const webhookTypes = [
"basic",
"typeform",
"sendgrid",
//"shopify",
//"twitter",
//"stripe",
] as const;
const requestType = [
"declare type WebHookRequest {",
" /**",
" * Webhook Request object",
" */",
"static params:string[]",
"static query:string",
"static body:any",
"static headers:any",
"static url:string",
"}",
].join("\n");
export const parserExtraLibs = [
requestType,
`type Parser = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
];
export const conditionExtraLibs = [
requestType,
`type Condition = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
];
const additionalVariables = [
{
key: "req",
description: "webhook request",
},
];
export const webhookBasic = {
name: "Basic",
parser: {
additionalVariables,
extraLibs: parserExtraLibs,
template: (
table: TableSettings
) => `const basicParser: Parser = 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;
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return body;
`
}
}`,
},
condition: {
additionalVariables,
extraLibs: conditionExtraLibs,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<Typography color="text.disabled">
<WarningIcon aria-label="Warning" style={{ verticalAlign: "bottom" }} />
&nbsp; Specialized verification is not currently available for basic
webhooks, you can add your own verification logic in the conditions
section below.
</Typography>
);
},
};
export default webhookBasic;

View File

@@ -0,0 +1,6 @@
import basic from "./basic";
import typeform from "./typeform";
import sendgrid from "./sendgrid";
import webform from "./webform";
export { basic, typeform, sendgrid, webform };

View File

@@ -0,0 +1,76 @@
import { Typography, Link, TextField } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { TableSettings } from "@src/types/table";
import { IWebhook } from "@src/components/TableToolbar/Webhooks/utils";
export const webhookSendgrid = {
name: "SendGrid",
parser: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const sendgridParser: Parser = async ({ req, db, ref }) => {
const { body } = req
const eventHandler = async (sgEvent) => {
// Event handlers can be modiefed to preform different actions based on the sendgrid event
// List of events & docs : https://docs.sendgrid.com/for-developers/tracking-events/event#events
const { event, docPath } = sgEvent
// event param is provided by default
// however docPath or other custom parameter needs be passed in the custom_args variable in Sengrid Extension
return db.doc(docPath).update({ sgStatus: event })
}
//
if (Array.isArray(body)) {
// when multiple events are passed in one call
await Promise.allSettled(body.map(eventHandler))
} else eventHandler(body)
};`,
},
condition: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>
Enable Signed Event Webhooks on SendGrid by following{" "}
<Link
href="https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features#the-signed-event-webhook"
target="_blank"
rel="noopener noreferrer"
variant="inherit"
>
these instructions
<InlineOpenInNewIcon />
</Link>
<br />
Then add the secret below.
</Typography>
<TextField
id="sendgrid-verification-key"
label="Verification key"
value={webhookObject.auth.secret}
fullWidth
multiline
onChange={(e) => {
setWebhookObject({
...webhookObject,
auth: { ...webhookObject.auth, secret: e.target.value },
});
}}
/>
</>
);
},
};
export default webhookSendgrid;

View File

@@ -0,0 +1,113 @@
import { Typography, Link, TextField } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { TableSettings } from "@src/types/table";
import { IWebhook } from "@src/components/TableToolbar/Webhooks/utils";
export const webhookTypeform = {
name: "Typeform",
parser: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const typeformParser: Parser = 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
const submission = ({
_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],
};
}
}, {}),
})
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...submission,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return submission
`
}
};`,
},
condition: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>
Add a secret to your Typeform webhook config by following{" "}
<Link
href="https://developers.typeform.com/webhooks/secure-your-webhooks/"
target="_blank"
rel="noopener noreferrer"
variant="inherit"
>
these instructions
<InlineOpenInNewIcon />
</Link>
<br />
Then add the secret below.
</Typography>
<TextField
id="typeform-secret"
label="Typeform secret"
fullWidth
value={webhookObject.auth.secret}
onChange={(e) => {
setWebhookObject({
...webhookObject,
auth: { ...webhookObject.auth, secret: e.target.value },
});
}}
/>
</>
);
},
};
export default webhookTypeform;

View File

@@ -0,0 +1,99 @@
import { Typography, Link, TextField } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { TableSettings } from "@src/types/table";
import { IWebhook } from "@src/components/TableToolbar/Webhooks/utils";
export const webhook = {
name: "Web Form",
type: "webform",
parser: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const formParser: Parser = 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;
${
table.audit !== false
? `
// auditField
const ${
table.auditFieldCreatedBy ?? "_createdBy"
} = await rowy.metadata.serviceAccountUser()
return {
...body,
${table.auditFieldCreatedBy ?? "_createdBy"}
}
`
: `
return body;
`
}
}`,
},
condition: {
additionalVariables: null,
extraLibs: null,
template: (
table: TableSettings
) => `const condition: Condition = async({ref,req,db}) => {
// feel free to add your own code logic here
return true;
}`,
},
auth: (webhookObject: IWebhook, setWebhookObject: (w: IWebhook) => void) => {
return (
<>
<Typography gutterBottom>
Add your capture key
<Link
href=""
target="_blank"
rel="noopener noreferrer"
variant="inherit"
>
these instructions
<InlineOpenInNewIcon />
</Link>
<br />
Then add the secret below.
</Typography>
<TextField
id="api-key"
label="API Key"
fullWidth
value={webhookObject.auth.secret}
onChange={(e) => {
setWebhookObject({
...webhookObject,
auth: { ...webhookObject.auth, secret: e.target.value },
});
}}
/>
<TextField
id="minimum-score"
label="Minimum score"
fullWidth
type="number"
value={webhookObject.auth.minimumScore}
onChange={(e) => {
setWebhookObject({
...webhookObject,
auth: { ...webhookObject.auth, minimumScore: e.target.value },
});
}}
/>
</>
);
},
};
export default webhook;

View File

@@ -0,0 +1,45 @@
import { IWebhookModalStepProps } from "./WebhookModal";
import { FormControlLabel, Checkbox, Typography } from "@mui/material";
import { webhookSchemas } from "./utils";
export default function Step1Endpoint({
webhookObject,
setWebhookObject,
}: IWebhookModalStepProps) {
return (
<>
<Typography variant="inherit" paragraph>
Verification prevents malicious requests from being sent to this webhook
endpoint
</Typography>
<FormControlLabel
control={
<Checkbox
checked={webhookObject.auth?.enabled}
onClick={() =>
setWebhookObject({
...webhookObject,
auth: {
...webhookObject.auth,
enabled: !webhookObject?.auth?.enabled,
},
})
}
/>
}
label="Enable verification for this webhook"
sx={{ mb: 2 }}
/>
{webhookObject.auth?.enabled &&
webhookSchemas[webhookObject.type].auth(
webhookObject,
setWebhookObject
)}
{}
</>
);
}

View File

@@ -0,0 +1,67 @@
import { IWebhookModalStepProps } from "./WebhookModal";
import useStateRef from "react-usestateref";
import { Typography } from "@mui/material";
import CodeEditor from "@src/components/CodeEditor";
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import { webhookSchemas } from "./utils";
const diagnosticsOptions = {
noSemanticValidation: false,
noSyntaxValidation: false,
noSuggestionDiagnostics: true,
};
export default function Step3Conditions({
webhookObject,
setWebhookObject,
setValidation,
validationRef,
}: IWebhookModalStepProps) {
const [, setConditionEditorActive, conditionEditorActiveRef] =
useStateRef(false);
return (
<>
<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>
<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={diagnosticsOptions}
onMount={() => setConditionEditorActive(true)}
onUnmount={() => setConditionEditorActive(false)}
extraLibs={
webhookSchemas[webhookObject.type]?.condition?.extraLibs ??
webhookSchemas["basic"].condition.extraLibs
}
/>
</div>
<CodeEditorHelper
docLink={WIKI_LINKS.webhooks}
additionalVariables={
webhookSchemas[webhookObject.type].condition?.additionalVariables ??
webhookSchemas["basic"].condition.additionalVariables
}
/>
</>
);
}

View File

@@ -0,0 +1,87 @@
import { IWebhookModalStepProps } from "./WebhookModal";
import { upperFirst } from "lodash-es";
import useStateRef from "react-usestateref";
import { Typography, Link } from "@mui/material";
import CodeEditor from "@src/components/CodeEditor";
import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { WIKI_LINKS } from "@src/constants/externalLinks";
import { parserExtraLibs } from "./utils";
const additionalVariables = [
{
key: "req",
description: "webhook request",
},
];
const diagnosticsOptions = {
noSemanticValidation: false,
noSyntaxValidation: false,
noSuggestionDiagnostics: true,
};
export default function Step4Body({
webhookObject,
setWebhookObject,
setValidation,
validationRef,
}: IWebhookModalStepProps) {
const [, setBodyEditorActive, bodyEditorActiveRef] = useStateRef(false);
return (
<>
<Typography gutterBottom>
Write a function to parse webhook requests. Return an object, which will
be added as a new row.{" "}
<Link
href={
WIKI_LINKS[
`webhooks${upperFirst(webhookObject.type)}` as "webhooks"
] || WIKI_LINKS.webhooks
}
target="_blank"
rel="noopener noreferrer"
>
Docs
<InlineOpenInNewIcon />
</Link>
</Typography>
<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={diagnosticsOptions}
onMount={() => setBodyEditorActive(true)}
onUnmount={() => setBodyEditorActive(false)}
extraLibs={parserExtraLibs}
/>
</div>
<CodeEditorHelper
docLink={
WIKI_LINKS[
`webhooks${upperFirst(webhookObject.type)}` as "webhooks"
] || WIKI_LINKS.webhooks
}
additionalVariables={additionalVariables}
/>
</>
);
}

View File

@@ -0,0 +1,195 @@
import { useAtom, useSetAtom } from "jotai";
import { format, formatRelative } from "date-fns";
import {
Stack,
List,
ListItem,
ListItemText,
Avatar,
IconButton,
Switch,
Tooltip,
Typography,
} from "@mui/material";
import WebhookIcon from "@mui/icons-material/Webhook";
import LogsIcon from "@src/assets/icons/CloudLogs";
import EditIcon from "@mui/icons-material/EditOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import LinkIcon from "@mui/icons-material/Link";
import EmptyState from "@src/components/EmptyState";
import { webhookNames, IWebhook } from "./utils";
import { DATE_TIME_FORMAT } from "@src/constants/dates";
import {
globalScope,
projectSettingsAtom,
tableModalAtom,
} from "@src/atoms/globalScope";
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
import { cloudLogFiltersAtom } from "@src/components/TableToolbar/CloudLogs/utils";
export interface IWebhookListProps {
webhooks: IWebhook[];
handleUpdateActive: (index: number, active: boolean) => void;
handleEdit: (index: number) => void;
handleDelete: (index: number) => void;
}
export default function WebhookList({
webhooks,
handleUpdateActive,
handleEdit,
handleDelete,
}: IWebhookListProps) {
const [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const setModal = useSetAtom(tableModalAtom, globalScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, globalScope);
const baseUrl = `${projectSettings.services?.hooks}/wh/${tableSettings.collection}/`;
if (webhooks.length === 0)
return (
<EmptyState
message="Add your first webhook above"
description="Your webhooks will appear here"
Icon={WebhookIcon}
style={{ height: 89 * 3 - 1 }}
/>
);
return (
<List style={{ paddingTop: 0, minHeight: 89 * 3 - 1 }} disablePadding>
{webhooks.map((webhook, index) => (
<ListItem
disableGutters
dense={false}
divider={index !== webhooks.length - 1}
children={
<ListItemText
primary={webhook.name}
secondary={
<>
{webhookNames[webhook.type]}{" "}
<code
style={{
userSelect: "all",
paddingRight: 0,
}}
>
<Tooltip title="Endpoint ID">
<span>{webhook.endpoint}</span>
</Tooltip>
<Tooltip title="Copy endpoint URL">
<IconButton
onClick={() =>
navigator.clipboard.writeText(
baseUrl + webhook.endpoint
)
}
size="small"
color="secondary"
sx={{ my: (20 - 32) / 2 / 8 }}
>
<LinkIcon fontSize="small" />
</IconButton>
</Tooltip>
</code>
</>
}
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={() => {
setModal("cloudLogs");
setCloudLogFilters({
type: "webhook",
timeRange: { type: "days", value: 7 },
webhook: [webhook.endpoint],
});
}}
>
<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>
}
sx={{
flexWrap: { xs: "wrap", sm: "nowrap" },
"& .MuiListItemSecondaryAction-root": {
position: { xs: "static", sm: "absolute" },
width: { xs: "100%", sm: "auto" },
transform: { xs: "none", sm: "translateY(-50%)" },
},
pr: { xs: 0, sm: 216 / 8 },
}}
/>
))}
</List>
);
}

View File

@@ -0,0 +1,232 @@
import { useState } from "react";
import { useAtom, useSetAtom } from "jotai";
import { isEqual } from "lodash-es";
import useStateRef from "react-usestateref";
import {
Grid,
TextField,
FormControlLabel,
Switch,
Stack,
IconButton,
Tooltip,
Typography,
} from "@mui/material";
import CopyIcon from "@src/assets/icons/Copy";
import Modal, { IModalProps } from "@src/components/Modal";
import SteppedAccordion from "@src/components/SteppedAccordion";
import Step1Auth from "./Step1Auth";
import Step2Conditions from "./Step2Conditions";
import Step3Body from "./Step3Parser";
import {
globalScope,
projectSettingsAtom,
confirmDialogAtom,
} from "@src/atoms/globalScope";
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
import { webhookNames, IWebhook } from "./utils";
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 [projectSettings] = useAtom(projectSettingsAtom, globalScope);
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [webhookObject, setWebhookObject] = useState<IWebhook>(initialObject);
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,
};
const baseUrl = `${projectSettings.services?.hooks}/wh/${tableSettings.collection}/`;
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>
<Stack direction="row" alignItems="center" style={{ marginTop: 0 }}>
<Typography
variant="inherit"
style={{ whiteSpace: "nowrap", flexShrink: 0 }}
>
Endpoint URL:
</Typography>
&nbsp;
<Typography
variant="caption"
style={{ overflowX: "auto", whiteSpace: "nowrap", flexGrow: 1 }}
>
<code>
{baseUrl}
{webhookObject.endpoint}
</code>
</Typography>
<Tooltip title="Copy endpoint URL">
<IconButton
onClick={() =>
navigator.clipboard.writeText(
`${baseUrl}${webhookObject.endpoint}`
)
}
sx={{ flexShrink: 0, mr: -0.75 }}
>
<CopyIcon />
</IconButton>
</Tooltip>
</Stack>
<SteppedAccordion
steps={[
{
id: "verification",
title: "Verification",
optional: true,
content: <Step1Auth {...stepProps} />,
},
{
id: "conditions",
title: "Conditions",
optional: true,
content: <Step2Conditions {...stepProps} />,
},
{
id: "parser",
title: "Parser",
content: <Step3Body {...stepProps} />,
},
]}
style={{ marginTop: "var(--dialog-contents-spacing)" }}
/>
</>
}
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) {
confirm({
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,267 @@
import { useState } from "react";
import { useAtom, useSetAtom } from "jotai";
import { isEqual } from "lodash-es";
import { useSnackbar } from "notistack";
import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton";
import WebhookIcon from "@mui/icons-material/Webhook";
import Modal from "@src/components/Modal";
import AddWebhookButton from "./AddWebhookButton";
import WebhookList from "./WebhookList";
import WebhookModal from "./WebhookModal";
import {
globalScope,
currentUserAtom,
rowyRunAtom,
compatibleRowyRunVersionAtom,
rowyRunModalAtom,
confirmDialogAtom,
tableModalAtom,
} from "@src/atoms/globalScope";
import {
tableScope,
tableSettingsAtom,
tableSchemaAtom,
updateTableSchemaAtom,
} from "@src/atoms/tableScope";
import { emptyWebhookObject, IWebhook, WebhookType } from "./utils";
import { runRoutes } from "@src/constants/runRoutes";
import { analytics, logEvent } from "@src/analytics";
import { getTableSchemaPath } from "@src/utils/table";
export default function Webhooks() {
const [currentUser] = useAtom(currentUserAtom, globalScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
const [compatibleRowyRunVersion] = useAtom(
compatibleRowyRunVersionAtom,
globalScope
);
const openRowyRunModal = useSetAtom(rowyRunModalAtom, globalScope);
const confirm = useSetAtom(confirmDialogAtom, globalScope);
const [modal, setModal] = useAtom(tableModalAtom, globalScope);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope);
const { enqueueSnackbar } = useSnackbar();
const currentWebhooks = (tableSchema.webhooks ?? []) as IWebhook[];
const [localWebhooksObjects, setLocalWebhooksObjects] =
useState(currentWebhooks);
const open = modal === "webhooks";
const setOpen = (open: boolean) => setModal(open ? "webhooks" : null);
const [webhookModal, setWebhookModal] = useState<{
mode: "add" | "update";
webhookObject: IWebhook;
index?: number;
} | null>(null);
if (!compatibleRowyRunVersion({ minVersion: "1.2.0" }))
return (
<TableToolbarButton
title="Webhooks"
onClick={() =>
openRowyRunModal({ feature: "Webhooks", version: "1.2.0" })
}
icon={<WebhookIcon />}
/>
);
const edited = !isEqual(currentWebhooks, localWebhooksObjects);
const handleOpen = () => setOpen(true);
const handleClose = (
_setOpen: React.Dispatch<React.SetStateAction<boolean>>
) => {
if (edited) {
_setOpen(true);
confirm({
title: "Discard changes?",
confirm: "Discard",
handleConfirm: () => {
_setOpen(false);
setLocalWebhooksObjects(currentWebhooks);
setOpen(false);
},
});
} else {
setOpen(false);
}
};
const handleSaveWebhooks = async (callback?: Function) => {
if (updateTableSchema)
await updateTableSchema({ webhooks: localWebhooksObjects });
if (callback) callback();
setOpen(false);
// TODO: convert to async function that awaits for the document write to complete
await new Promise((resolve) => setTimeout(resolve, 500));
};
const handleSaveDeploy = () =>
handleSaveWebhooks(async () => {
try {
if (rowyRun) {
const resp = await rowyRun({
service: "hooks",
route: runRoutes.publishWebhooks,
body: {
tableConfigPath: getTableSchemaPath(tableSettings),
tablePath: tableSettings.collection,
},
});
enqueueSnackbar(resp.message, {
variant: resp.success ? "success" : "error",
});
logEvent(analytics, "published_webhooks");
}
} catch (e) {
console.error(e);
}
});
const handleAddWebhook = (webhookObject: IWebhook) => {
setLocalWebhooksObjects([...localWebhooksObjects, webhookObject]);
logEvent(analytics, "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;
}
})
);
logEvent(analytics, "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 handleEdit = (index: number) => {
setWebhookModal({
mode: "update",
webhookObject: localWebhooksObjects[index],
index,
});
};
const handleDelete = (index: number) => {
confirm({
title: `Delete “${localWebhooksObjects[index].name}”?`,
body: "This webhook will be permanently deleted when you save",
confirm: "Confirm",
handleConfirm: () => {
setLocalWebhooksObjects(
localWebhooksObjects.filter((_, i) => i !== index)
);
},
});
};
const currentEditor = () => ({
displayName: currentUser?.displayName ?? "Unknown user",
photoURL: currentUser?.photoURL ?? "",
lastUpdate: Date.now(),
});
const activeWebhookCount = localWebhooksObjects.filter(
(webhook) => webhook.active
).length;
return (
<>
<TableToolbarButton
title="Webhooks"
onClick={handleOpen}
icon={<WebhookIcon />}
/>
{open && (
<Modal
onClose={handleClose}
disableBackdropClick={edited}
disableEscapeKeyDown={edited}
maxWidth="sm"
fullWidth
title={`Webhooks (${activeWebhookCount}\u2009/\u2009${localWebhooksObjects.length})`}
header={
<AddWebhookButton
handleAddWebhook={(type: WebhookType) => {
setWebhookModal({
mode: "add",
webhookObject: emptyWebhookObject(
type,
currentEditor(),
tableSettings
),
});
}}
variant={
localWebhooksObjects.length === 0 ? "contained" : "outlined"
}
/>
}
children={
<WebhookList
webhooks={localWebhooksObjects}
handleUpdateActive={handleUpdateActive}
handleEdit={handleEdit}
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}
/>
)}
</>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./Webhooks";
export { default } from "./Webhooks";

View File

@@ -0,0 +1,101 @@
import { TableSettings } from "@src/types/table";
import { generateId } from "@src/utils/table";
import { typeform, basic, sendgrid, webform } from "./Schemas";
export const webhookTypes = [
"basic",
"typeform",
"sendgrid",
"webform",
//"shopify",
//"twitter",
//"stripe",
] as const;
const requestType = [
"declare type WebHookRequest {",
" /**",
" * Webhook Request object",
" */",
"static params:string[]",
"static query:string",
"static body:any",
"static headers:any",
"}",
].join("\n");
export const parserExtraLibs = [
requestType,
`type Parser = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
];
export const conditionExtraLibs = [
requestType,
`type Condition = (args:{req:WebHookRequest,db: FirebaseFirestore.Firestore,ref: FirebaseFirestore.CollectionReference,res:{
send:(v:any)=>void
sendStatus:(status:number)=>void
}}) => Promise<any>;`,
];
const additionalVariables = [
{
key: "req",
description: "webhook request",
},
];
export type WebhookType = typeof webhookTypes[number];
export const webhookNames: Record<WebhookType, string> = {
sendgrid: "SendGrid",
typeform: "Typeform",
//github:"GitHub",
// shopify: "Shopify",
// twitter: "Twitter",
// stripe: "Stripe",
basic: "Basic",
webform: "Web form",
};
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;
auth?: any;
}
export const webhookSchemas = {
basic,
typeform,
sendgrid,
webform,
};
export function emptyWebhookObject(
type: WebhookType,
user: IWebhookEditor,
table: TableSettings
): IWebhook {
return {
name: `${type} webhook`,
active: false,
endpoint: generateId(),
type,
parser: webhookSchemas[type].parser?.template(table),
conditions: webhookSchemas[type].condition?.template(table),
lastEditor: user,
};
}

View File

@@ -0,0 +1,12 @@
type Condition = (args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
res: Response;
}) => Promise<any>;
type Parser = (args: {
req: WebHookRequest;
db: FirebaseFirestore.Firestore;
ref: FirebaseFirestore.CollectionReference;
}) => Promise<any>;

View File

@@ -19,7 +19,7 @@ import {
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
import { useActionParams } from "./FormDialog/Context";
import { runRoutes } from "@src/constants/runRoutes";
import { getSchemaPath } from "@src/utils/table";
import { getTableSchemaPath } from "@src/utils/table";
const replacer = (data: any) => (m: string, key: string) => {
const objKey = key.split(":")[0];
@@ -80,7 +80,7 @@ export default function ActionFab({
ref: { path: ref.path },
column: { ...column, editor: undefined },
action,
schemaDocPath: getSchemaPath(tableSettings),
schemaDocPath: getTableSchemaPath(tableSettings),
actionParams,
});

View File

@@ -27,7 +27,7 @@ import { getLabel } from "@src/components/fields/Connector/utils";
import { useSnackbar } from "notistack";
import { globalScope, rowyRunAtom } from "@src/atoms/globalScope";
import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope";
import { getSchemaPath } from "@src/utils/table";
import { getTableSchemaPath } from "@src/utils/table";
export interface IPopupContentsProps
extends Omit<IConnectorSelectProps, "className" | "TextFieldProps"> {}
@@ -74,7 +74,7 @@ export default function PopupContents({
body: {
columnKey: column.key,
query: query,
schemaDocPath: getSchemaPath(tableSettings),
schemaDocPath: getTableSchemaPath(tableSettings),
rowDocPath: docRef.path,
},
});

View File

@@ -231,6 +231,7 @@ export default function NavDrawer({
<NavItem
{...({ component: "button" } as any)}
style={{ textAlign: "left" }}
sx={{ mb: 1 }}
onClick={(e) => {
if (closeDrawer) closeDrawer(e);
openTableSettingsDialog({});

View File

@@ -53,7 +53,7 @@ export default function NavTableSection({
</NavItem>
<Collapse in={open}>
<List disablePadding>
<List style={{ paddingTop: 0 }}>
{tables.map((table) => {
const route = getTableRoute(table);
@@ -68,7 +68,7 @@ export default function NavTableSection({
<ListItemText
primary={table.name}
sx={{
"& .MuiListItemText-primary": {
"&& .MuiListItemText-primary": {
fontWeight: "normal",
fontSize: ".8125rem",
},

View File

@@ -4,6 +4,8 @@ import type {
DocumentData,
DocumentReference,
} from "firebase/firestore";
import { IExtension } from "@src/components/TableToolbar/Extensions/utils";
import { IWebhook } from "@src/components/TableToolbar/Webhooks/utils";
/**
* A standard function to update a doc in the database
@@ -73,9 +75,12 @@ export type TableSchema = {
functionConfigPath?: string;
functionBuilderRef?: any;
extensionObjects?: any[];
extensionObjects?: IExtension[];
compiledExtension?: string;
webhooks?: any[];
webhooks?: IWebhook[];
/** @deprecated Migrate to Extensions */
sparks?: string;
};
export type ColumnConfig = {

View File

@@ -106,7 +106,7 @@ const formatPathRegex = /\/[^\/]+\/([^\/]+)/g;
* @param tableType - primaryCollection (default) or collectionGroup
* @returns Path to the tables schema doc
*/
export const getSchemaPath = (
export const getTableSchemaPath = (
tableSettings: Pick<TableSettings, "id" | "tableType">
) =>
(tableSettings.tableType === "collectionGroup"

5
src/utils/ui.ts Normal file
View File

@@ -0,0 +1,5 @@
export const isTargetInsideBox = (target: Element, box: Element) => {
const targetRect = target.getBoundingClientRect();
const boxRect = box.getBoundingClientRect();
return targetRect.y < boxRect.y + boxRect.height;
};

View File

@@ -4442,6 +4442,11 @@ algoliasearch@^4.13.1:
"@algolia/requester-node-http" "4.13.1"
"@algolia/transporter" "4.13.1"
anser@^1.4.1:
version "1.4.10"
resolved "https://registry.yarnpkg.com/anser/-/anser-1.4.10.tgz#befa3eddf282684bd03b63dcda3927aef8c2e35b"
integrity sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==
ansi-escapes@^4.2.1, ansi-escapes@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.1.tgz#a5c47cc43181f1f38ffd7076837700d395522a61"
@@ -4495,6 +4500,14 @@ ansi-styles@^6.0.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.1.0.tgz#87313c102b8118abd57371afab34618bf7350ed3"
integrity sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==
ansi-to-react@^6.1.6:
version "6.1.6"
resolved "https://registry.yarnpkg.com/ansi-to-react/-/ansi-to-react-6.1.6.tgz#d6fe15ecd4351df626a08121b1646adfe6c02ccb"
integrity sha512-+HWn72GKydtupxX9TORBedqOMsJRiKTqaLUKW8txSBZw9iBpzPKLI8KOu4WzwD4R7hSv1zEspobY6LwlWvwZ6Q==
dependencies:
anser "^1.4.1"
escape-carriage "^1.3.0"
anymatch@^3.0.3:
version "3.1.1"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142"
@@ -6270,6 +6283,11 @@ escalade@^3.1.1:
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
escape-carriage@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/escape-carriage/-/escape-carriage-1.3.0.tgz#71006b2d4da8cb6828686addafcb094239c742f3"
integrity sha512-ATWi5MD8QlAGQOeMgI8zTp671BG8aKvAC0M7yenlxU4CRLGO/sKthxVUyjiOFKjHdIo+6dZZUNFgHFeVEaKfGQ==
escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
@@ -9934,6 +9952,11 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
pb-util@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/pb-util/-/pb-util-1.0.3.tgz#013800d6c2d1e1d1950d595f8b7d7e608ceca9e1"
integrity sha512-8+weUH2YEYnPf5sTpZ3q7Drq41tSEL8vDSU96/CzSvu2qrbspbjbbuKLjHocAQpmyMbICTcvovVl3cETwxwIkQ==
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
@@ -11116,6 +11139,11 @@ react-transition-group@^4.4.2:
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-usestateref@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/react-usestateref/-/react-usestateref-1.0.8.tgz#b40519af0d6f3b3822c70eb5db80f7d47f1b1ff5"
integrity sha512-whaE6H0XGarFKwZ3EYbpHBsRRCLZqdochzg/C7e+b6VFMTA3LS3K4ZfpI4NT40iy83jG89rGXrw70P9iDfOdsA==
react@^18.0.0:
version "18.0.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96"