diff --git a/src/atoms/tableScope/ui.ts b/src/atoms/tableScope/ui.ts index 3d11a081..7182a41d 100644 --- a/src/atoms/tableScope/ui.ts +++ b/src/atoms/tableScope/ui.ts @@ -109,7 +109,7 @@ export type ImportAirtableData = { records: Record[] }; /** Store import CSV popover and wizard state */ export const importCsvAtom = atom<{ - importType: "csv" | "tsv"; + importType: "csv" | "tsv" | "json"; csvData: ImportCsvData | null; }>({ importType: "csv", csvData: null }); @@ -142,14 +142,26 @@ export const selectedCellAtom = atom(null); export const contextMenuTargetAtom = atom(null); export type CloudLogFilters = { - type: "webhook" | "functions" | "audit" | "build"; + type: "extension" | "webhook" | "column" | "audit" | "build" | "functions"; timeRange: | { type: "seconds" | "minutes" | "hours" | "days"; value: number } | { type: "range"; start: Date; end: Date }; severity?: Array; webhook?: string[]; + extension?: string[]; + column?: string[]; auditRowId?: string; buildLogExpanded?: number; + functionType?: ( + | "connector" + | "derivative-script" + | "action" + | "derivative-function" + | "extension" + | "defaultValue" + | "hooks" + )[]; + loggingSource?: ("backend-scripts" | "backend-function" | "hooks")[]; }; /** Store cloud log modal filters in URL */ export const cloudLogFiltersAtom = atomWithHash( diff --git a/src/components/CodeEditor/CodeEditorHelper.tsx b/src/components/CodeEditor/CodeEditorHelper.tsx index 299c2134..04326e3c 100644 --- a/src/components/CodeEditor/CodeEditorHelper.tsx +++ b/src/components/CodeEditor/CodeEditorHelper.tsx @@ -38,6 +38,10 @@ export default function CodeEditorHelper({ key: "rowy", description: `rowy provides a set of functions that are commonly used, such as easy file uploads & access to GCP Secret Manager`, }, + { + key: "logging", + description: `logging.log is encouraged to replace console.log`, + }, ]; return ( diff --git a/src/components/CodeEditor/extensions.d.ts b/src/components/CodeEditor/extensions.d.ts index dd592d05..22af5756 100644 --- a/src/components/CodeEditor/extensions.d.ts +++ b/src/components/CodeEditor/extensions.d.ts @@ -26,6 +26,7 @@ type ExtensionContext = { extensionBody: any; }; RULES_UTILS: any; + logging: RowyLogging; }; // extension body definition diff --git a/src/components/CodeEditor/rowy.d.ts b/src/components/CodeEditor/rowy.d.ts index f6852cce..42582dd0 100644 --- a/src/components/CodeEditor/rowy.d.ts +++ b/src/components/CodeEditor/rowy.d.ts @@ -17,6 +17,11 @@ type uploadOptions = { folderPath?: string; fileName?: string; }; +type RowyLogging = { + log: (payload: any) => void; + warn: (payload: any) => void; + error: (payload: any) => void; +}; interface Rowy { metadata: { /** diff --git a/src/components/ColumnMenu/ColumnMenu.tsx b/src/components/ColumnMenu/ColumnMenu.tsx index cf025217..7dff465e 100644 --- a/src/components/ColumnMenu/ColumnMenu.tsx +++ b/src/components/ColumnMenu/ColumnMenu.tsx @@ -20,11 +20,11 @@ import { ColumnPlusBefore as ColumnPlusBeforeIcon, ColumnPlusAfter as ColumnPlusAfterIcon, ColumnRemove as ColumnRemoveIcon, + CloudLogs as LogsIcon, } from "@src/assets/icons"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import EditIcon from "@mui/icons-material/EditOutlined"; -// import ReorderIcon from "@mui/icons-material/Reorder"; import SettingsIcon from "@mui/icons-material/SettingsOutlined"; import EvalIcon from "@mui/icons-material/PlayCircleOutline"; @@ -51,6 +51,8 @@ import { tableFiltersPopoverAtom, tableNextPageAtom, tableSchemaAtom, + cloudLogFiltersAtom, + tableModalAtom, } from "@src/atoms/tableScope"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; @@ -107,6 +109,8 @@ export default function ColumnMenu({ ); const [tableNextPage] = useAtom(tableNextPageAtom, tableScope); const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const setModal = useSetAtom(tableModalAtom, tableScope); + const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, tableScope); const snackLogContext = useSnackLogContext(); const [altPress] = useAtom(altPressAtom, projectScope); @@ -314,26 +318,29 @@ export default function ColumnMenu({ }, disabled: !isConfigurable, }, - // { - // label: "Re-order", - // icon: , - // onClick: () => alert("REORDER"), - // }, - - // { - // label: "Hide for everyone", - // activeLabel: "Show", - // icon: , - // activeIcon: , - // onClick: () => { - // actions.update(column.key, { hidden: !column.hidden }); - // handleClose(); - // }, - // active: column.hidden, - // color: "error" as "error", - // }, ]; + if ( + column?.config?.defaultValue?.type === "dynamic" || + [FieldType.action, FieldType.derivative, FieldType.connector].includes( + column.type + ) + ) { + configActions.push({ + key: "logs", + label: altPress ? "Logs" : "Logs…", + icon: , + onClick: () => { + setModal("cloudLogs"); + setCloudLogFilters({ + type: "column", + timeRange: { type: "days", value: 7 }, + column: [column.key], + }); + }, + }); + } + // TODO: Generalize const handleEvaluateAll = async () => { try { diff --git a/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx b/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx index 5213eb8a..3cc78e2a 100644 --- a/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx +++ b/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx @@ -52,11 +52,11 @@ function CodeEditor({ type, column, handleChange }: ICodeEditorProps) { } else if (column.config?.defaultValue?.dynamicValueFn) { dynamicValueFn = column.config?.defaultValue?.dynamicValueFn; } else if (column.config?.defaultValue?.script) { - dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{ + dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth,logging})=>{ ${column.config?.defaultValue.script} }`; } else { - dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{ + dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth,logging})=>{ // Write your default value code here // for example: // generate random hex color diff --git a/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts b/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts index 0e50bb2d..779bd9be 100644 --- a/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts +++ b/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts @@ -4,5 +4,6 @@ type DefaultValueContext = { storage: firebasestorage.Storage; db: FirebaseFirestore.Firestore; auth: firebaseauth.BaseAuth; + logging: RowyLogging; }; type DefaultValue = (context: DefaultValueContext) => "PLACEHOLDER_OUTPUT_TYPE"; diff --git a/src/components/Table/TableCell/EditorCellController.tsx b/src/components/Table/TableCell/EditorCellController.tsx index a8978c8d..c80380d4 100644 --- a/src/components/Table/TableCell/EditorCellController.tsx +++ b/src/components/Table/TableCell/EditorCellController.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect } from "react"; import useStateRef from "react-usestateref"; import { useSetAtom } from "jotai"; import { isEqual } from "lodash-es"; +import { useSnackbar } from "notistack"; import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; import type { @@ -45,6 +46,8 @@ export default function EditorCellController({ const [isDirty, setIsDirty, isDirtyRef] = useStateRef(false); const updateField = useSetAtom(updateFieldAtom, tableScope); + const { enqueueSnackbar } = useSnackbar(); + // When this cell’s data has updated, update the local value if // it’s not dirty and the value is different useEffect(() => { @@ -53,17 +56,20 @@ export default function EditorCellController({ }, [isDirty, localValueRef, setLocalValue, value]); // This is where we update the documents - const handleSubmit = () => { + const handleSubmit = async () => { // props.disabled should always be false as withRenderTableCell would // render DisplayCell instead of EditorCell if (props.disabled || !isDirtyRef.current) return; - - updateField({ - path: props._rowy_ref.path, - fieldName: props.column.fieldName, - value: localValueRef.current, - deleteField: localValueRef.current === undefined, - }); + try { + await updateField({ + path: props._rowy_ref.path, + fieldName: props.column.fieldName, + value: localValueRef.current, + deleteField: localValueRef.current === undefined, + }); + } catch (e) { + enqueueSnackbar((e as Error).message, { variant: "error" }); + } }; useLayoutEffect(() => { diff --git a/src/components/TableModals/CloudLogsModal/CloudLogItem.tsx b/src/components/TableModals/CloudLogsModal/CloudLogItem.tsx index f82a75f1..9e0887f3 100644 --- a/src/components/TableModals/CloudLogsModal/CloudLogItem.tsx +++ b/src/components/TableModals/CloudLogsModal/CloudLogItem.tsx @@ -187,22 +187,32 @@ export default function CloudLogItem({ )} - {data.payload === "textPayload" && data.textPayload} - {get(data, "httpRequest.requestUrl")?.split(".run.app").pop()} - {data.payload === "jsonPayload" && ( - - {data.jsonPayload.error}{" "} - + {data.logName.endsWith("rowy-logging") && data.jsonPayload.payload ? ( + <> + {typeof data.jsonPayload.payload === "string" + ? data.jsonPayload.payload + : JSON.stringify(data.jsonPayload.payload)} + + ) : ( + <> + {data.payload === "textPayload" && data.textPayload} + {get(data, "httpRequest.requestUrl")?.split(".run.app").pop()} + {data.payload === "jsonPayload" && ( + + {data.jsonPayload.error}{" "} + + )} + {data.payload === "jsonPayload" && + stringify(data.jsonPayload.body ?? data.jsonPayload, { + space: 2, + })} + )} - {data.payload === "jsonPayload" && - stringify(data.jsonPayload.body ?? data.jsonPayload, { - space: 2, - })} diff --git a/src/components/TableModals/CloudLogsModal/CloudLogList.tsx b/src/components/TableModals/CloudLogsModal/CloudLogList.tsx index 5e5382ee..93093d38 100644 --- a/src/components/TableModals/CloudLogsModal/CloudLogList.tsx +++ b/src/components/TableModals/CloudLogsModal/CloudLogList.tsx @@ -70,6 +70,13 @@ export default function CloudLogList({ items, ...props }: ICloudLogListProps) { "jsonPayload.rowyUser.displayName", // Webhook event "jsonPayload.params.endpoint", + // Rowy Logging + "jsonPayload.functionType", + "jsonPayload.loggingSource", + "jsonPayload.extensionName", + "jsonPayload.extensionType", + "jsonPayload.webhookName", + "jsonPayload.fieldName", ]} /> diff --git a/src/components/TableModals/CloudLogsModal/CloudLogSeverityIcon.tsx b/src/components/TableModals/CloudLogsModal/CloudLogSeverityIcon.tsx index 0cf0dc6e..f8413f65 100644 --- a/src/components/TableModals/CloudLogsModal/CloudLogSeverityIcon.tsx +++ b/src/components/TableModals/CloudLogsModal/CloudLogSeverityIcon.tsx @@ -22,6 +22,12 @@ export const SEVERITY_LEVELS = { EMERGENCY: "One or more systems are unusable.", }; +export const SEVERITY_LEVELS_ROWY = { + DEFAULT: "The log entry has no assigned severity level.", + WARNING: "Warning events might cause problems.", + ERROR: "Error events are likely to cause problems.", +}; + export interface ICloudLogSeverityIconProps extends SvgIconProps { severity: keyof typeof SEVERITY_LEVELS; } diff --git a/src/components/TableModals/CloudLogsModal/CloudLogsModal.tsx b/src/components/TableModals/CloudLogsModal/CloudLogsModal.tsx index e69acc44..76cfe719 100644 --- a/src/components/TableModals/CloudLogsModal/CloudLogsModal.tsx +++ b/src/components/TableModals/CloudLogsModal/CloudLogsModal.tsx @@ -1,6 +1,6 @@ import useSWR from "swr"; import { useAtom } from "jotai"; -import { startCase } from "lodash-es"; +import { startCase, upperCase } from "lodash-es"; import { ITableModalProps } from "@src/components/TableModals"; import { @@ -12,9 +12,12 @@ import { TextField, InputAdornment, Button, + Box, + CircularProgress, } from "@mui/material"; import RefreshIcon from "@mui/icons-material/Refresh"; import { CloudLogs as LogsIcon } from "@src/assets/icons"; +import ClearIcon from "@mui/icons-material/Clear"; import Modal from "@src/components/Modal"; import TableToolbarButton from "@src/components/TableToolbar/TableToolbarButton"; @@ -23,7 +26,10 @@ 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 CloudLogSeverityIcon, { + SEVERITY_LEVELS, + SEVERITY_LEVELS_ROWY, +} from "./CloudLogSeverityIcon"; import { projectScope, @@ -38,6 +44,7 @@ import { cloudLogFiltersAtom, } from "@src/atoms/tableScope"; import { cloudLogFetcher } from "./utils"; +import { FieldType } from "@src/constants/fields"; export default function CloudLogsModal({ onClose }: ITableModalProps) { const [projectId] = useAtom(projectIdAtom, projectScope); @@ -92,7 +99,7 @@ export default function CloudLogsModal({ onClose }: ITableModalProps) { "&, & .MuiTab-root": { minHeight: { md: "var(--dialog-title-height)" }, }, - ml: { md: 18 }, + ml: { md: 20 }, mr: { md: 40 / 8 + 3 }, minHeight: 32, @@ -110,18 +117,35 @@ export default function CloudLogsModal({ onClose }: ITableModalProps) { + onChange={(_, newType) => { setCloudLogFilters((c) => ({ - type: v, + type: newType, timeRange: c.timeRange, - })) - } + })); + if ( + [ + "extension", + "webhook", + "column", + "audit", + "functions", + ].includes(newType) + ) { + setTimeout(() => { + mutate(); + }, 0); + } + }} aria-label="Filter by log type" > - Webhooks - Functions + Extension + Webhook + Column Audit Build + + Functions (legacy) + ) : ( )} - {cloudLogFilters.type === "webhook" && ( - ({ - 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} {option.value} - - )} - /> - )} - {cloudLogFilters.type === "audit" && ( - - setCloudLogFilters((prev) => ({ - ...prev, - auditRowId: e.target.value, - })) - } - InputProps={{ - startAdornment: ( - - {tableSettings.collection}/ - - ), - }} - className="labelHorizontal" - sx={{ - "& .MuiInputBase-root, & .MuiInputBase-input": { - typography: "body2", - fontFamily: "mono", - }, - "& .MuiInputAdornment-positionStart": { - m: "0 !important", - pointerEvents: "none", - }, - "& .MuiInputBase-input": { pl: 0 }, - }} - /> - )} - - {/* Spacer */}
{cloudLogFilters.type !== "build" && ( <> - {!isValidating && Array.isArray(data) && ( - - {data.length} entries - - )} - - - 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{" "} - - - ); - - return `Severity (${cloudLogFilters.severity.length})`; - }, - }, + + {isValidating ? "" : `${data?.length ?? 0} entries`} + + { + setCloudLogFilters((prev) => ({ + ...prev, + functionType: undefined, + loggingSource: undefined, + webhook: undefined, + extension: undefined, + severity: undefined, + })); }} - itemRenderer={(option) => ( - <> - - {startCase(option.value.toLowerCase())} - - )} - /> - - setCloudLogFilters((c) => ({ ...c, timeRange: value })) - } + title="Clear Filters" + icon={} + disabled={isValidating} /> mutate()} title="Refresh" - icon={} + icon={ + isValidating ? ( + + ) : ( + + ) + } disabled={isValidating} /> )} - - {isValidating && ( - - )} - - {/* {logQueryUrl} */} } > {cloudLogFilters.type === "build" ? ( - ) : Array.isArray(data) && data.length > 0 ? ( - <> - - {cloudLogFilters.timeRange.type !== "range" && ( - - )} - - ) : isValidating ? ( - ) : ( - + + {["extension", "webhook", "column", "audit", "functions"].includes( + cloudLogFilters.type + ) ? ( + + {cloudLogFilters.type === "functions" ? ( + + ) : null} + {cloudLogFilters.type === "extension" ? ( + <> + ({ + label: x.name, + value: x.name, + type: x.type, + })) + : [] + } + value={cloudLogFilters.extension ?? []} + onChange={(v) => + setCloudLogFilters((prev) => ({ ...prev, extension: v })) + } + TextFieldProps={{ + id: "extension", + className: "labelHorizontal", + sx: { + width: "100%", + "& .MuiInputBase-root": { width: "100%" }, + }, + fullWidth: false, + placeholder: "Extension", + SelectProps: { + renderValue: () => { + if (cloudLogFilters?.extension?.length === 1) { + return `Extension (${cloudLogFilters.extension[0]})`; + } else if (cloudLogFilters?.extension?.length) { + return `Extension (${cloudLogFilters.extension.length})`; + } else { + return `Extension`; + } + }, + }, + }} + itemRenderer={(option) => ( + <> + {option.label} {option.type} + + )} + /> + + ) : null} + {cloudLogFilters.type === "webhook" ? ( + ({ + label: x.name, + value: x.endpoint, + })) + : [] + } + value={cloudLogFilters.webhook ?? []} + onChange={(v) => + setCloudLogFilters((prev) => ({ ...prev, webhook: v })) + } + TextFieldProps={{ + id: "webhook", + className: "labelHorizontal", + sx: { + width: "100%", + "& .MuiInputBase-root": { width: "100%" }, + }, + fullWidth: false, + SelectProps: { + renderValue: () => { + if (cloudLogFilters?.webhook?.length) { + return `Webhook (${cloudLogFilters.webhook.length})`; + } else { + return `Webhook`; + } + }, + }, + }} + itemRenderer={(option) => ( + <> + {option.label} {option.value} + + )} + /> + ) : null} + {cloudLogFilters.type === "column" ? ( + <> + + config?.config?.defaultValue?.type === "dynamic" || + [ + FieldType.action, + FieldType.derivative, + FieldType.connector, + ].includes(config.type) + ) + .map(([key, config]) => ({ + label: config.name, + value: key, + type: config.type, + }))} + value={cloudLogFilters.column ?? []} + onChange={(v) => + setCloudLogFilters((prev) => ({ ...prev, column: v })) + } + TextFieldProps={{ + id: "column", + className: "labelHorizontal", + sx: { + width: "100%", + "& .MuiInputBase-root": { width: "100%" }, + }, + fullWidth: false, + placeholder: "Column", + SelectProps: { + renderValue: () => { + if (cloudLogFilters?.column?.length === 1) { + return `Column (${cloudLogFilters.column[0]})`; + } else if (cloudLogFilters?.column?.length) { + return `Column (${cloudLogFilters.column.length})`; + } else { + return `Column`; + } + }, + }, + }} + itemRenderer={(option) => ( + <> + {option.label} {option.value}  + {option.type} + + )} + /> + + ) : null} + {cloudLogFilters.type === "audit" ? ( + <> + + setCloudLogFilters((prev) => ({ + ...prev, + auditRowId: e.target.value, + })) + } + InputProps={{ + startAdornment: ( + + {tableSettings.collection}/ + + ), + }} + className="labelHorizontal" + sx={{ + width: "100%", + "& .MuiInputBase-root, & .MuiInputBase-input": { + width: "100%", + typography: "body2", + fontFamily: "mono", + }, + "& .MuiInputAdornment-positionStart": { + m: "0 !important", + pointerEvents: "none", + }, + "& .MuiInputBase-input": { pl: 0 }, + "& .MuiFormLabel-root": { + whiteSpace: "nowrap", + }, + }} + /> + + ) : null} + + setCloudLogFilters((prev) => ({ ...prev, severity })) + } + TextFieldProps={{ + style: { width: 200 }, + placeholder: "Severity", + SelectProps: { + renderValue: () => { + if ( + !Array.isArray(cloudLogFilters.severity) || + cloudLogFilters.severity.length === 0 + ) + return `Severity`; + + if (cloudLogFilters.severity.length === 1) + return ( + <> + Severity{" "} + + + ); + + return `Severity (${cloudLogFilters.severity.length})`; + }, + }, + }} + itemRenderer={(option) => ( + <> + + {startCase(option.value.toLowerCase())} + + )} + /> + + setCloudLogFilters((c) => ({ ...c, timeRange: value })) + } + /> + + ) : null} + + {Array.isArray(data) && data.length > 0 ? ( + + + {cloudLogFilters.timeRange.type !== "range" && ( + + )} + + ) : isValidating ? ( + + ) : ( + + )} + + )} ); diff --git a/src/components/TableModals/CloudLogsModal/TimeRangeSelect.tsx b/src/components/TableModals/CloudLogsModal/TimeRangeSelect.tsx index b6da74a8..9450348a 100644 --- a/src/components/TableModals/CloudLogsModal/TimeRangeSelect.tsx +++ b/src/components/TableModals/CloudLogsModal/TimeRangeSelect.tsx @@ -19,7 +19,9 @@ export default function TimeRangeSelect({ ...props }: ITimeRangeSelectProps) { return ( -
+
{value && value.type !== "range" && ( { + return `jsonPayload.loggingSource = "${loggingSource}"`; + }) + .join(encodeURIComponent(" OR ")) + ); + } + switch (cloudLogFilters.type) { + case "extension": + logQuery.push(`logName = "projects/${projectId}/logs/rowy-logging"`); + logQuery.push(`jsonPayload.tablePath : "${tablePath}"`); + if (cloudLogFilters?.extension?.length) { + logQuery.push( + cloudLogFilters.extension + .map((extensionName) => { + return `jsonPayload.extensionName = "${extensionName}"`; + }) + .join(encodeURIComponent(" OR ")) + ); + } else { + logQuery.push(`jsonPayload.functionType = "extension"`); + } + break; + 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(`jsonPayload.tablePath : "${tablePath}"`); + if (cloudLogFilters?.webhook?.length) { logQuery.push( cloudLogFilters.webhook .map((id) => `jsonPayload.url : "${id}"`) .join(encodeURIComponent(" OR ")) ); + } else { + logQuery.push(`jsonPayload.functionType = "hooks"`); + } + break; + + case "column": + logQuery.push(`jsonPayload.tablePath : "${tablePath}"`); + if (cloudLogFilters?.column?.length) { + logQuery.push( + cloudLogFilters.column + .map((column) => { + return `jsonPayload.fieldName = "${column}"`; + }) + .join(encodeURIComponent(" OR ")) + ); + } else { + logQuery.push( + [ + "connector", + "derivative-script", + "action", + "derivative-function", + "defaultValue", + ] + .map((functionType) => { + return `jsonPayload.functionType = "${functionType}"`; + }) + .join(encodeURIComponent(" OR ")) + ); + } break; case "audit": diff --git a/src/components/TableModals/ExtensionsModal/ExtensionList.tsx b/src/components/TableModals/ExtensionsModal/ExtensionList.tsx index dbd63a9e..a0d5b82f 100644 --- a/src/components/TableModals/ExtensionsModal/ExtensionList.tsx +++ b/src/components/TableModals/ExtensionsModal/ExtensionList.tsx @@ -14,6 +14,7 @@ import { import { Extension as ExtensionIcon, Copy as DuplicateIcon, + CloudLogs as LogsIcon, } from "@src/assets/icons"; import EditIcon from "@mui/icons-material/EditOutlined"; import DeleteIcon from "@mui/icons-material/DeleteOutlined"; @@ -21,6 +22,12 @@ 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"; +import { useSetAtom } from "jotai"; +import { + cloudLogFiltersAtom, + tableModalAtom, + tableScope, +} from "@src/atoms/tableScope"; export interface IExtensionListProps { extensions: IExtension[]; @@ -37,6 +44,9 @@ export default function ExtensionList({ handleEdit, handleDelete, }: IExtensionListProps) { + const setModal = useSetAtom(tableModalAtom, tableScope); + const setCloudLogFilters = useSetAtom(cloudLogFiltersAtom, tableScope); + if (extensions.length === 0) return ( + + { + setModal("cloudLogs"); + setCloudLogFilters({ + type: "extension", + timeRange: { type: "days", value: 7 }, + extension: [extensionObject.name], + }); + }} + > + + + { + task: `const extensionBody: TaskBody = async({row, db, change, ref, logging}) => { // 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: @@ -87,7 +87,7 @@ const extensionBodyTemplate = { }) */ }`, - docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref}) => { + docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref, logging}) => { // feel free to add your own code logic here return ({ @@ -96,7 +96,7 @@ const extensionBodyTemplate = { targetPath: "", // fill in the path here }) }`, - historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref}) => { + historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref, logging}) => { // feel free to add your own code logic here return ({ @@ -104,7 +104,7 @@ const extensionBodyTemplate = { collectionId: "historySnapshots", // optionally change the sub-collection id of where the history snapshots are stored }) }`, - algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref}) => { + algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref, logging}) => { // feel free to add your own code logic here return ({ @@ -114,7 +114,7 @@ const extensionBodyTemplate = { objectID: ref.id, // algolia object ID, ref.id is one possible choice }) }`, - meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref}) => { + meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref, logging}) => { // feel free to add your own code logic here return({ @@ -124,7 +124,7 @@ const extensionBodyTemplate = { objectID: ref.id, // algolia object ID, ref.id is one possible choice }) }`, - bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref}) => { + bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref, logging}) => { // feel free to add your own code logic here return ({ @@ -134,7 +134,7 @@ const extensionBodyTemplate = { objectID: ref.id, // algolia object ID, ref.id is one possible choice }) }`, - slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref}) => { + slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref, logging}) => { // feel free to add your own code logic here return ({ @@ -144,7 +144,7 @@ const extensionBodyTemplate = { attachments: [], // the attachments parameter to pass in to slack api }) }`, - sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref}) => { + sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref, logging}) => { // feel free to add your own code logic here return ({ @@ -164,7 +164,7 @@ const extensionBodyTemplate = { }, }) }`, - apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref}) => { + apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref, logging}) => { // feel free to add your own code logic here return ({ @@ -174,7 +174,7 @@ const extensionBodyTemplate = { callback: ()=>{}, }) }`, - twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref}) => { + twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref, logging}) => { /** * * Setup twilio secret key: https://docs.rowy.io/extensions/twilio-message#secret-manager-setup @@ -190,7 +190,7 @@ const extensionBodyTemplate = { body: "Hi there!" // message text }) }`, - pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref}) => { + pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref, logging}) => { // you can FCM token from the row or from the user document in the database // const FCMtoken = row.FCMtoken // or push through topic @@ -238,13 +238,14 @@ export function emptyExtensionObject( extensionBody: extensionBodyTemplate[type] ?? extensionBodyTemplate["task"], requiredFields: [], trackedFields: [], - conditions: `const condition: Condition = async({row, change}) => { + conditions: `const condition: Condition = async({row, change, logging}) => { // feel free to add your own code logic here return true; }`, lastEditor: user, }; } + export function sparkToExtensionObjects( sparkConfig: string, user: IExtensionEditor diff --git a/src/components/TableModals/WebhooksModal/Schemas/basic.tsx b/src/components/TableModals/WebhooksModal/Schemas/basic.tsx index 7f9e4d11..9d8e7b8c 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/basic.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/basic.tsx @@ -21,17 +21,33 @@ const requestType = [ 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;`, + `type Parser = ( + args: { + req: WebHookRequest; + db: FirebaseFirestore.Firestore; + ref: FirebaseFirestore.CollectionReference; + res: { + send: (v:any)=>void; + sendStatus: (status:number)=>void + }; + logging: RowyLogging; + } + ) => Promise;`, ]; 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;`, + `type Condition = ( + args: { + req: WebHookRequest; + db: FirebaseFirestore.Firestore; + ref: FirebaseFirestore.CollectionReference; + res: { + send: (v:any)=>void; + sendStatus: (status:number)=>void; + }; + logging: RowyLogging; + } + ) => Promise;`, ]; const additionalVariables = [ @@ -48,30 +64,29 @@ export const webhookBasic = { 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; - ` - } - + ) => `const basicParser: Parser = async({req, db, ref, logging}) => { + // 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: { @@ -79,7 +94,7 @@ export const webhookBasic = { extraLibs: conditionExtraLibs, template: ( table: TableSettings - ) => `const condition: Condition = async({ref,req,db}) => { + ) => `const condition: Condition = async({ref, req, db, logging}) => { // feel free to add your own code logic here return true; }`, diff --git a/src/components/TableModals/WebhooksModal/Schemas/sendgrid.tsx b/src/components/TableModals/WebhooksModal/Schemas/sendgrid.tsx index b557dd78..b251697f 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/sendgrid.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/sendgrid.tsx @@ -13,7 +13,7 @@ export const webhookSendgrid = { extraLibs: null, template: ( table: TableSettings - ) => `const sendgridParser: Parser = async ({ req, db, ref }) => { + ) => `const sendgridParser: Parser = async ({ req, db, ref, logging }) => { const { body } = req const eventHandler = async (sgEvent) => { // Event handlers can be modiefed to preform different actions based on the sendgrid event @@ -35,7 +35,7 @@ export const webhookSendgrid = { extraLibs: null, template: ( table: TableSettings - ) => `const condition: Condition = async({ref,req,db}) => { + ) => `const condition: Condition = async({ref, req, db, logging}) => { // feel free to add your own code logic here return true; }`, diff --git a/src/components/TableModals/WebhooksModal/Schemas/stripe.tsx b/src/components/TableModals/WebhooksModal/Schemas/stripe.tsx index 31eb4aff..acfb2dfe 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/stripe.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/stripe.tsx @@ -17,7 +17,7 @@ export const webhookStripe = { extraLibs: null, template: ( table: TableSettings - ) => `const sendgridParser: Parser = async ({ req, db, ref }) => { + ) => `const sendgridParser: Parser = async ({ req, db, ref, logging }) => { const event = req.body switch (event.type) { case "payment_intent.succeeded": @@ -34,7 +34,7 @@ export const webhookStripe = { extraLibs: null, template: ( table: TableSettings - ) => `const condition: Condition = async({ref,req,db}) => { + ) => `const condition: Condition = async({ref, req, db, logging}) => { // feel free to add your own code logic here return true; }`, diff --git a/src/components/TableModals/WebhooksModal/Schemas/typeform.tsx b/src/components/TableModals/WebhooksModal/Schemas/typeform.tsx index 5fd4ce7d..9c509efd 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/typeform.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/typeform.tsx @@ -13,7 +13,7 @@ export const webhookTypeform = { extraLibs: null, template: ( table: TableSettings - ) => `const typeformParser: Parser = async({req, db,ref}) =>{ + ) => `const typeformParser: Parser = async({req, db, ref, logging}) =>{ // 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 @@ -73,7 +73,7 @@ export const webhookTypeform = { extraLibs: null, template: ( table: TableSettings - ) => `const condition: Condition = async({ref,req,db}) => { + ) => `const condition: Condition = async({ref, req, db, logging}) => { // feel free to add your own code logic here return true; }`, diff --git a/src/components/TableModals/WebhooksModal/Schemas/webform.tsx b/src/components/TableModals/WebhooksModal/Schemas/webform.tsx index bf5e8cda..7bed061d 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/webform.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/webform.tsx @@ -14,7 +14,7 @@ export const webhook = { extraLibs: null, template: ( table: TableSettings - ) => `const formParser: Parser = async({req, db,ref}) => { + ) => `const formParser: Parser = async({req, db, ref, logging}) => { // request is the request object from the webhook // db is the database object // ref is the reference to collection of the table @@ -45,7 +45,7 @@ export const webhook = { extraLibs: null, template: ( table: TableSettings - ) => `const condition: Condition = async({ref,req,db}) => { + ) => `const condition: Condition = async({ref, req, db, logging}) => { // feel free to add your own code logic here return true; }`, diff --git a/src/components/TableModals/WebhooksModal/utils.tsx b/src/components/TableModals/WebhooksModal/utils.tsx index bb27a64a..c4292604 100644 --- a/src/components/TableModals/WebhooksModal/utils.tsx +++ b/src/components/TableModals/WebhooksModal/utils.tsx @@ -26,17 +26,33 @@ const requestType = [ 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;`, + `type Parser = ( + args: { + req: WebHookRequest; + db: FirebaseFirestore.Firestore; + ref: FirebaseFirestore.CollectionReference; + res: { + send: (v:any)=>void; + sendStatus: (status:number)=>void + }; + logging: RowyLogging; + } + ) => Promise;`, ]; 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;`, + `type Condition = ( + args: { + req:WebHookRequest, + db: FirebaseFirestore.Firestore, + ref: FirebaseFirestore.CollectionReference, + res: { + send: (v:any)=>void + sendStatus: (status:number)=>void + }; + logging: RowyLogging; + } + ) => Promise;`, ]; const additionalVariables = [ diff --git a/src/components/TableModals/WebhooksModal/webhooks.d.ts b/src/components/TableModals/WebhooksModal/webhooks.d.ts index 4a8715f8..65df0530 100644 --- a/src/components/TableModals/WebhooksModal/webhooks.d.ts +++ b/src/components/TableModals/WebhooksModal/webhooks.d.ts @@ -3,10 +3,12 @@ type Condition = (args: { db: FirebaseFirestore.Firestore; ref: FirebaseFirestore.CollectionReference; res: Response; + logging: RowyLogging; }) => Promise; type Parser = (args: { req: WebHookRequest; db: FirebaseFirestore.Firestore; ref: FirebaseFirestore.CollectionReference; + logging: RowyLogging; }) => Promise; diff --git a/src/components/TableToolbar/HiddenFields.tsx b/src/components/TableToolbar/HiddenFields.tsx index 0a537713..ff1fc86a 100644 --- a/src/components/TableToolbar/HiddenFields.tsx +++ b/src/components/TableToolbar/HiddenFields.tsx @@ -29,6 +29,7 @@ import { projectScope, userSettingsAtom, updateUserSettingsAtom, + userRolesAtom, } from "@src/atoms/projectScope"; import { tableScope, @@ -41,6 +42,9 @@ export default function HiddenFields() { const buttonRef = useRef(null); const [userSettings] = useAtom(userSettingsAtom, projectScope); + const [userRoles] = useAtom(userRolesAtom, projectScope); + const canEditColumns = + userRoles.includes("ADMIN") || userRoles.includes("OPS"); const [tableId] = useAtom(tableIdAtom, tableScope); const [open, setOpen] = useState(false); @@ -76,7 +80,7 @@ export default function HiddenFields() { setOpen(false); }; - // disable drag if search box is not empty + // disable drag if search box is not empty and user does not have permission const [disableDrag, setDisableDrag] = useState(false); const renderOption: AutocompleteProps< any, @@ -92,7 +96,7 @@ export default function HiddenFields() { {(provided) => (
  • @@ -106,7 +110,9 @@ export default function HiddenFields() { { marginRight: "6px", opacity: (theme) => - disableDrag ? theme.palette.action.disabledOpacity : 1, + disableDrag || !canEditColumns + ? theme.palette.action.disabledOpacity + : 1, }, ]} /> @@ -159,7 +165,8 @@ export default function HiddenFields() { // updates column on drag end function handleOnDragEnd(result: DropResult) { - if (!result.destination) return; + if (!result.destination || result.destination.index === result.source.index) + return; updateColumn({ key: result.draggableId, config: {}, @@ -169,7 +176,7 @@ export default function HiddenFields() { // checks whether to disable reordering when search filter is applied function checkToDisableDrag(e: ChangeEvent) { - setDisableDrag(e.target.value !== ""); + setDisableDrag(e.target.value !== "" || !canEditColumns); } const ListboxComponent = forwardRef(function ListboxComponent( @@ -205,7 +212,9 @@ export default function HiddenFields() { <> } - onClick={() => setOpen((o) => !o)} + onClick={() => { + setOpen((o) => !o); + }} active={hiddenFields.length > 0} ref={buttonRef} > diff --git a/src/components/TableToolbar/ImportData/ImportData.tsx b/src/components/TableToolbar/ImportData/ImportData.tsx index 01b2aa4e..4bdfc216 100644 --- a/src/components/TableToolbar/ImportData/ImportData.tsx +++ b/src/components/TableToolbar/ImportData/ImportData.tsx @@ -91,7 +91,7 @@ export default function ImportData({ render, PopoverProps }: IImportDataProps) { variant="fullWidth" > (importMethodRef.current = ImportMethod.csv)} /> diff --git a/src/components/TableToolbar/ImportData/ImportFromCsv.tsx b/src/components/TableToolbar/ImportData/ImportFromCsv.tsx index 40ec3f84..9739efab 100644 --- a/src/components/TableToolbar/ImportData/ImportFromCsv.tsx +++ b/src/components/TableToolbar/ImportData/ImportFromCsv.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { useAtom, useSetAtom } from "jotai"; import { parse } from "csv-parse/browser/esm"; +import { parse as parseJSON } from "json2csv"; import { useDropzone } from "react-dropzone"; import { useDebouncedCallback } from "use-debounce"; import { useSnackbar } from "notistack"; @@ -34,7 +35,70 @@ export enum ImportMethod { url = "url", } -export default function ImportFromCsv() { +enum FileType { + CSV = "text/csv", + TSV = "text/tab-separated-values", + JSON = "application/json", +} + +// extract the column names and return the names +function extractFields(data: JSON[]): string[] { + let columns = new Set(); + for (let jsonRow of data) { + columns = new Set([...columns, ...Object.keys(jsonRow)]); + } + columns.delete("id"); + return [...columns]; +} + +function convertJSONToCSV(rawData: string): string | false { + let rawDataJSONified: JSON[]; + try { + rawDataJSONified = JSON.parse(rawData); + } catch (e) { + return false; + } + if (rawDataJSONified.length < 1) { + return false; + } + const fields = extractFields(rawDataJSONified); + const opts = { fields }; + + try { + const csv = parseJSON(rawDataJSONified, opts); + return csv; + } catch (err) { + return false; + } +} + +function hasProperJsonStructure(raw: string) { + try { + raw = JSON.parse(raw); + const type = Object.prototype.toString.call(raw); + // we don't want '[object Object]' + return type === "[object Array]"; + } catch (err) { + return false; + } +} + +function checkIsJson(raw: string): boolean { + raw = typeof raw !== "string" ? JSON.stringify(raw) : raw; + + try { + raw = JSON.parse(raw); + } catch (e) { + return false; + } + + if (typeof raw === "object" && raw !== null) { + return true; + } + return false; +} + +export default function ImportFromFile() { const [{ importType: importTypeCsv, csvData }, setImportCsv] = useAtom( importCsvAtom, tableScope @@ -55,6 +119,20 @@ export default function ImportFromCsv() { }; }, [setImportCsv]); + const parseFile = useCallback((rawData: string) => { + if (importTypeRef.current === "json") { + if (!hasProperJsonStructure(rawData)) { + return setError("Invalid Structure! It must be an Array"); + } + const converted = convertJSONToCSV(rawData); + if (!converted) { + return setError("No columns detected"); + } + rawData = converted; + } + parseCsv(rawData); + }, []); + const parseCsv = useCallback( (csvString: string) => parse(csvString, { delimiter: [",", "\t"] }, (err, rows) => { @@ -71,6 +149,7 @@ export default function ImportFromCsv() { {} ) ); + console.log(mappedRows); setImportCsv({ importType: importTypeRef.current, csvData: { columns, rows: mappedRows }, @@ -86,13 +165,17 @@ export default function ImportFromCsv() { async (acceptedFiles: File[]) => { try { const file = acceptedFiles[0]; - const reader = new FileReader(); - reader.onload = (event: any) => parseCsv(event.target.result); - reader.readAsText(file); importTypeRef.current = - file.type === "text/tab-separated-values" ? "tsv" : "csv"; + file.type === FileType.TSV + ? "tsv" + : file.type === FileType.JSON + ? "json" + : "csv"; + const reader = new FileReader(); + reader.onload = (event: any) => parseFile(event.target.result); + reader.readAsText(file); } catch (error) { - enqueueSnackbar(`Please import a .tsv or .csv file`, { + enqueueSnackbar(`Please import a .tsv or .csv or .json file`, { variant: "error", anchorOrigin: { vertical: "top", @@ -107,10 +190,14 @@ export default function ImportFromCsv() { const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, multiple: false, - accept: ["text/csv", "text/tab-separated-values"], + accept: [FileType.CSV, FileType.TSV, FileType.JSON], }); function setDataTypeRef(data: string) { + if (checkIsJson(data)) { + return (importTypeRef.current = "json"); + } + const getFirstLine = data?.match(/^(.*)/)?.[0]; /* * Catching edge case with regex @@ -128,8 +215,8 @@ export default function ImportFromCsv() { : (importTypeRef.current = "csv"); } const handlePaste = useDebouncedCallback((value: string) => { - parseCsv(value); setDataTypeRef(value); + parseFile(value); }, 1000); const handleUrl = useDebouncedCallback((value: string) => { @@ -138,8 +225,8 @@ export default function ImportFromCsv() { fetch(value, { mode: "no-cors" }) .then((res) => res.text()) .then((data) => { - parseCsv(data); setDataTypeRef(data); + parseFile(data); setLoading(false); }) .catch((e) => { @@ -217,7 +304,7 @@ export default function ImportFromCsv() { {isDragActive ? ( - Drop CSV or TSV file here… + Drop CSV or TSV or JSON file here… ) : ( <> @@ -227,8 +314,8 @@ export default function ImportFromCsv() { {validCsv - ? "Valid CSV or TSV" - : "Click to upload or drop CSV or TSV file here"} + ? "Valid CSV or TSV or JSON" + : "Click to upload or drop CSV or TSV or JSON file here"} @@ -249,7 +336,7 @@ export default function ImportFromCsv() { inputProps={{ minRows: 3 }} autoFocus fullWidth - label="Paste CSV or TSV text" + label="Paste CSV or TSV or JSON text" placeholder="column, column, …" onChange={(e) => { if (csvData !== null) @@ -279,7 +366,7 @@ export default function ImportFromCsv() { variant="filled" autoFocus fullWidth - label="Paste URL to CSV or TSV file" + label="Paste URL to CSV or TSV or JSON file" placeholder="https://" onChange={(e) => { if (csvData !== null) diff --git a/src/components/fields/Action/Settings.tsx b/src/components/fields/Action/Settings.tsx index 1933818c..735423ef 100644 --- a/src/components/fields/Action/Settings.tsx +++ b/src/components/fields/Action/Settings.tsx @@ -130,7 +130,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => { : config?.runFn ? config.runFn : config?.script - ? `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => { + ? `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => { ${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")} }` : RUN_ACTION_TEMPLATE; @@ -140,7 +140,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => { : config.undoFn ? config.undoFn : get(config, "undo.script") - ? `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => { + ? `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => { ${get(config, "undo.script")} }` : UNDO_ACTION_TEMPLATE; diff --git a/src/components/fields/Action/action.d.ts b/src/components/fields/Action/action.d.ts index a4339978..1daab613 100644 --- a/src/components/fields/Action/action.d.ts +++ b/src/components/fields/Action/action.d.ts @@ -15,6 +15,7 @@ type ActionContext = { auth: firebaseauth.BaseAuth; actionParams: actionParams; user: ActionUser; + logging: RowyLogging; }; type ActionResult = { diff --git a/src/components/fields/Action/templates.ts b/src/components/fields/Action/templates.ts index 5701ef24..04b3ab23 100644 --- a/src/components/fields/Action/templates.ts +++ b/src/components/fields/Action/templates.ts @@ -1,4 +1,4 @@ -export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => { +export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => { // Write your action code here // for example: // const authToken = await rowy.secrets.get("service") @@ -26,7 +26,7 @@ export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,sto // checkout the documentation for more info: https://docs.rowy.io/field-types/action#script }`; -export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => { +export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => { // Write your undo code here // for example: // const authToken = await rowy.secrets.get("service") diff --git a/src/components/fields/Connector/connector.d.ts b/src/components/fields/Connector/connector.d.ts index d031d64b..c16585ae 100644 --- a/src/components/fields/Connector/connector.d.ts +++ b/src/components/fields/Connector/connector.d.ts @@ -15,6 +15,7 @@ type ConnectorContext = { auth: firebaseauth.BaseAuth; query: string; user: ConnectorUser; + logging: RowyLogging; }; type ConnectorResult = any[]; type Connector = ( diff --git a/src/components/fields/Connector/utils.ts b/src/components/fields/Connector/utils.ts index 17687fe7..88959895 100644 --- a/src/components/fields/Connector/utils.ts +++ b/src/components/fields/Connector/utils.ts @@ -11,7 +11,7 @@ export const replacer = (data: any) => (m: string, key: string) => { return get(data, objKey, defaultValue); }; -export const baseFunction = `const connectorFn: Connector = async ({query, row, user}) => { +export const baseFunction = `const connectorFn: Connector = async ({query, row, user, logging}) => { // TODO: Implement your service function here return []; };`; diff --git a/src/components/fields/Derivative/Settings.tsx b/src/components/fields/Derivative/Settings.tsx index 32ce65dc..7fe36869 100644 --- a/src/components/fields/Derivative/Settings.tsx +++ b/src/components/fields/Derivative/Settings.tsx @@ -65,10 +65,10 @@ export default function Settings({ : config.derivativeFn ? config.derivativeFn : config?.script - ? `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{ + ? `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{ ${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")} }` - : `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{ + : `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{ // Write your derivative code here // for example: // const sum = row.a + row.b; diff --git a/src/components/fields/Derivative/derivative.d.ts b/src/components/fields/Derivative/derivative.d.ts index 3af58e11..a56afeba 100644 --- a/src/components/fields/Derivative/derivative.d.ts +++ b/src/components/fields/Derivative/derivative.d.ts @@ -5,6 +5,7 @@ type DerivativeContext = { db: FirebaseFirestore.Firestore; auth: firebaseauth.BaseAuth; change: any; + logging: RowyLogging; }; type Derivative = (context: DerivativeContext) => "PLACEHOLDER_OUTPUT_TYPE"; diff --git a/src/hooks/useFirestoreDocWithAtom.ts b/src/hooks/useFirestoreDocWithAtom.ts index 49736600..b66351df 100644 --- a/src/hooks/useFirestoreDocWithAtom.ts +++ b/src/hooks/useFirestoreDocWithAtom.ts @@ -2,6 +2,8 @@ import { useEffect } from "react"; import useMemoValue from "use-memo-value"; import { useAtom, PrimitiveAtom, useSetAtom } from "jotai"; import { set } from "lodash-es"; +import { useSnackbar } from "notistack"; + import { Firestore, doc, @@ -64,6 +66,7 @@ export function useFirestoreDocWithAtom( dataScope ); const handleError = useErrorHandler(); + const { enqueueSnackbar } = useSnackbar(); // Create the doc ref and memoize using Firestore’s refEqual const memoizedDocRef = useMemoValue( @@ -145,7 +148,17 @@ export function useFirestoreDocWithAtom( } } - return setDoc(memoizedDocRef, updateToDb, { merge: true }); + setDataAtom((prev) => { + return { + ...prev, + ...updateToDb, + }; + }); + return setDoc(memoizedDocRef, updateToDb, { merge: true }).catch( + (e) => { + enqueueSnackbar((e as Error).message, { variant: "error" }); + } + ); }); } @@ -154,7 +167,13 @@ export function useFirestoreDocWithAtom( // reset the atom’s value to prevent writes if (updateDataAtom) setUpdateDataAtom(undefined); }; - }, [memoizedDocRef, updateDataAtom, setUpdateDataAtom]); + }, [ + memoizedDocRef, + updateDataAtom, + setUpdateDataAtom, + enqueueSnackbar, + setDataAtom, + ]); } export default useFirestoreDocWithAtom;