mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-28 16:06:41 +01:00
Merge branch 'data-layer-rewrite' of https://github.com/rowyio/rowy into data-layer-rewrite
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
107
src/components/TableToolbar/CloudLogs/BuildLogs/BuildLogList.tsx
Normal file
107
src/components/TableToolbar/CloudLogs/BuildLogs/BuildLogList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
210
src/components/TableToolbar/CloudLogs/BuildLogs/BuildLogs.tsx
Normal file
210
src/components/TableToolbar/CloudLogs/BuildLogs/BuildLogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
2
src/components/TableToolbar/CloudLogs/BuildLogs/index.ts
Normal file
2
src/components/TableToolbar/CloudLogs/BuildLogs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./BuildLogs";
|
||||
export { default } from "./BuildLogs";
|
||||
@@ -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 };
|
||||
}
|
||||
257
src/components/TableToolbar/CloudLogs/CloudLogItem.tsx
Normal file
257
src/components/TableToolbar/CloudLogs/CloudLogItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/TableToolbar/CloudLogs/CloudLogList.tsx
Normal file
85
src/components/TableToolbar/CloudLogs/CloudLogList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/components/TableToolbar/CloudLogs/CloudLogSeverityIcon.tsx
Normal file
100
src/components/TableToolbar/CloudLogs/CloudLogSeverityIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/TableToolbar/CloudLogs/CloudLogSubheader.tsx
Normal file
21
src/components/TableToolbar/CloudLogs/CloudLogSubheader.tsx
Normal 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;
|
||||
39
src/components/TableToolbar/CloudLogs/CloudLogs.tsx
Normal file
39
src/components/TableToolbar/CloudLogs/CloudLogs.tsx
Normal 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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
346
src/components/TableToolbar/CloudLogs/CloudLogsModal.tsx
Normal file
346
src/components/TableToolbar/CloudLogs/CloudLogsModal.tsx
Normal 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} <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>
|
||||
);
|
||||
}
|
||||
96
src/components/TableToolbar/CloudLogs/TimeRangeSelect.tsx
Normal file
96
src/components/TableToolbar/CloudLogs/TimeRangeSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/TableToolbar/CloudLogs/index.ts
Normal file
2
src/components/TableToolbar/CloudLogs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./CloudLogs";
|
||||
export { default } from "./CloudLogs";
|
||||
104
src/components/TableToolbar/CloudLogs/utils.ts
Normal file
104
src/components/TableToolbar/CloudLogs/utils.ts
Normal 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 [];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
156
src/components/TableToolbar/Extensions/ExtensionList.tsx
Normal file
156
src/components/TableToolbar/Extensions/ExtensionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/components/TableToolbar/Extensions/ExtensionMigration.tsx
Normal file
158
src/components/TableToolbar/Extensions/ExtensionMigration.tsx
Normal 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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
189
src/components/TableToolbar/Extensions/ExtensionModal.tsx
Normal file
189
src/components/TableToolbar/Extensions/ExtensionModal.tsx
Normal 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 I’m doing",
|
||||
cancel: "No, I’ll fix the errors",
|
||||
handleConfirm: handleAddOrUpdate,
|
||||
});
|
||||
} else {
|
||||
handleAddOrUpdate();
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
287
src/components/TableToolbar/Extensions/Extensions.tsx
Normal file
287
src/components/TableToolbar/Extensions/Extensions.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
src/components/TableToolbar/Extensions/Step1Triggers.tsx
Normal file
113
src/components/TableToolbar/Extensions/Step1Triggers.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
src/components/TableToolbar/Extensions/Step3Conditions.tsx
Normal file
85
src/components/TableToolbar/Extensions/Step3Conditions.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
src/components/TableToolbar/Extensions/Step4Body.tsx
Normal file
86
src/components/TableToolbar/Extensions/Step4Body.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
src/components/TableToolbar/Extensions/index.ts
Normal file
2
src/components/TableToolbar/Extensions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./Extensions";
|
||||
export { default } from "./Extensions";
|
||||
255
src/components/TableToolbar/Extensions/utils.ts
Normal file
255
src/components/TableToolbar/Extensions/utils.ts
Normal 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 ?? [];
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
83
src/components/TableToolbar/Webhooks/AddWebhookButton.tsx
Normal file
83
src/components/TableToolbar/Webhooks/AddWebhookButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
src/components/TableToolbar/Webhooks/Schemas/basic.tsx
Normal file
105
src/components/TableToolbar/Webhooks/Schemas/basic.tsx
Normal 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" }} />
|
||||
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;
|
||||
6
src/components/TableToolbar/Webhooks/Schemas/index.ts
Normal file
6
src/components/TableToolbar/Webhooks/Schemas/index.ts
Normal 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 };
|
||||
76
src/components/TableToolbar/Webhooks/Schemas/sendgrid.tsx
Normal file
76
src/components/TableToolbar/Webhooks/Schemas/sendgrid.tsx
Normal 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;
|
||||
113
src/components/TableToolbar/Webhooks/Schemas/typeform.tsx
Normal file
113
src/components/TableToolbar/Webhooks/Schemas/typeform.tsx
Normal 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;
|
||||
99
src/components/TableToolbar/Webhooks/Schemas/webform.tsx
Normal file
99
src/components/TableToolbar/Webhooks/Schemas/webform.tsx
Normal 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;
|
||||
45
src/components/TableToolbar/Webhooks/Step1Auth.tsx
Normal file
45
src/components/TableToolbar/Webhooks/Step1Auth.tsx
Normal 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
|
||||
)}
|
||||
{}
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
src/components/TableToolbar/Webhooks/Step2Conditions.tsx
Normal file
67
src/components/TableToolbar/Webhooks/Step2Conditions.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
src/components/TableToolbar/Webhooks/Step3Parser.tsx
Normal file
87
src/components/TableToolbar/Webhooks/Step3Parser.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
195
src/components/TableToolbar/Webhooks/WebhookList.tsx
Normal file
195
src/components/TableToolbar/Webhooks/WebhookList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
src/components/TableToolbar/Webhooks/WebhookModal.tsx
Normal file
232
src/components/TableToolbar/Webhooks/WebhookModal.tsx
Normal 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>
|
||||
|
||||
<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 I’m doing",
|
||||
cancel: "No, I’ll fix the errors",
|
||||
handleConfirm: handleAddOrUpdate,
|
||||
});
|
||||
} else {
|
||||
handleAddOrUpdate();
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
267
src/components/TableToolbar/Webhooks/Webhooks.tsx
Normal file
267
src/components/TableToolbar/Webhooks/Webhooks.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
src/components/TableToolbar/Webhooks/index.ts
Normal file
2
src/components/TableToolbar/Webhooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./Webhooks";
|
||||
export { default } from "./Webhooks";
|
||||
101
src/components/TableToolbar/Webhooks/utils.tsx
Normal file
101
src/components/TableToolbar/Webhooks/utils.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
12
src/components/TableToolbar/Webhooks/webhooks.d.ts
vendored
Normal file
12
src/components/TableToolbar/Webhooks/webhooks.d.ts
vendored
Normal 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>;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
9
src/types/table.d.ts
vendored
9
src/types/table.d.ts
vendored
@@ -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 = {
|
||||
|
||||
@@ -106,7 +106,7 @@ const formatPathRegex = /\/[^\/]+\/([^\/]+)/g;
|
||||
* @param tableType - primaryCollection (default) or collectionGroup
|
||||
* @returns Path to the table’s schema doc
|
||||
*/
|
||||
export const getSchemaPath = (
|
||||
export const getTableSchemaPath = (
|
||||
tableSettings: Pick<TableSettings, "id" | "tableType">
|
||||
) =>
|
||||
(tableSettings.tableType === "collectionGroup"
|
||||
|
||||
5
src/utils/ui.ts
Normal file
5
src/utils/ui.ts
Normal 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;
|
||||
};
|
||||
28
yarn.lock
28
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user