diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e81032d4..4b6c33baa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,7 @@ to start. ## Working on existing issues + Before you get started working on an [issue](https://github.com/rowyio/rowy/issues), please make sure to share that you are working on it by commenting on the issue and posting a message on #contributions channel in Rowy's [Discord](https://discord.com/invite/fjBugmvzZP). The maintainers will then assign the issue to you after making sure any relevant information or context in addition is provided before you can start on the task. Once you are assigned a task, please provide periodic updates or share any questions or roadblocks on either discord or the Github issue, so that the commmunity or the project maintainers can provide you any feedback or guidance as needed. If you are inactive for more than 1-2 week on a issue that was assigned to you, then we will assume you have stopped working on it and we will unassign it from you - so that we can give a chance to others in the community to work on it. diff --git a/src/atoms/projectScope/project.ts b/src/atoms/projectScope/project.ts index 0d9518ac5..222ee9427 100644 --- a/src/atoms/projectScope/project.ts +++ b/src/atoms/projectScope/project.ts @@ -48,7 +48,7 @@ export const tablesAtom = atom((get) => { return sortBy(tables, "name") .filter((table) => userRoles.includes("ADMIN") || Array.isArray(table.roles) - ? table.roles.some((role) => userRoles.includes(role)) + ? table.roles?.some((role) => userRoles.includes(role)) : false ) .map((table) => ({ @@ -105,7 +105,7 @@ export const deleteTableAtom = atom< /** Stores a function to get a table’s schema doc (without listener) */ export const getTableSchemaAtom = atom< - ((id: string) => Promise) | undefined + ((id: string, withSubtables?: boolean) => Promise) | undefined >(undefined); /** Roles used in the project based on table settings */ diff --git a/src/atoms/projectScope/rowyRun.ts b/src/atoms/projectScope/rowyRun.ts index bc6d35cd6..bb66d8f2a 100644 --- a/src/atoms/projectScope/rowyRun.ts +++ b/src/atoms/projectScope/rowyRun.ts @@ -73,7 +73,6 @@ export const rowyRunAtom = atom((get) => { handleNotSetUp, }: IRowyRunRequestProps): Promise => { if (!currentUser) { - console.log("Rowy Run: Not signed in", route.path); if (handleNotSetUp) handleNotSetUp(); return false; } @@ -85,7 +84,6 @@ export const rowyRunAtom = atom((get) => { ? rowyRunServices?.[service] : rowyRunUrl; if (!serviceUrl) { - console.log("Rowy Run: Not set up", route.path); if (handleNotSetUp) handleNotSetUp(); return false; } diff --git a/src/atoms/projectScope/ui.ts b/src/atoms/projectScope/ui.ts index 6d1abbc17..6f9d66d8c 100644 --- a/src/atoms/projectScope/ui.ts +++ b/src/atoms/projectScope/ui.ts @@ -141,7 +141,7 @@ export const tableSettingsDialogSchemaAtom = atom(async (get) => { const tableId = get(tableSettingsDialogIdAtom); const getTableSchema = get(getTableSchemaAtom); if (!tableId || !getTableSchema) return {} as TableSchema; - return getTableSchema(tableId); + return getTableSchema(tableId, true); }); /** Open the Get Started checklist from anywhere */ diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts index fdf8820c9..f8d519c48 100644 --- a/src/atoms/tableScope/table.ts +++ b/src/atoms/tableScope/table.ts @@ -164,7 +164,7 @@ export const tableRowsDbAtom = atom([]); /** Combine tableRowsLocal and tableRowsDb */ export const tableRowsAtom = atom((get) => { - const rowsDb = [...get(tableRowsDbAtom)]; + const rowsDb = get(tableRowsDbAtom); const rowsLocal = get(tableRowsLocalAtom); // Optimization: create Map of rowsDb by path to index for faster lookup @@ -178,15 +178,17 @@ export const tableRowsAtom = atom((get) => { if (rowsDbMap.has(row._rowy_ref.path)) { const index = rowsDbMap.get(row._rowy_ref.path)!; const merged = updateRowData({ ...rowsDb[index] }, row); - rowsDb.splice(index, 1); + rowsDbMap.delete(row._rowy_ref.path); return merged; } - return row; }); // Merge the two arrays - return [...rowsLocalToMerge, ...rowsDb]; + return [ + ...rowsLocalToMerge, + ...rowsDb.filter((row) => rowsDbMap.has(row._rowy_ref.path)), + ]; }); /** Store next page state for infinite scroll */ diff --git a/src/atoms/tableScope/ui.ts b/src/atoms/tableScope/ui.ts index 3d11a081e..7182a41d1 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/CodeEditor.tsx b/src/components/CodeEditor/CodeEditor.tsx index 2abfd45bf..6c5030856 100644 --- a/src/components/CodeEditor/CodeEditor.tsx +++ b/src/components/CodeEditor/CodeEditor.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import Editor, { EditorProps } from "@monaco-editor/react"; +import Editor, { EditorProps, Monaco } from "@monaco-editor/react"; import type { editor } from "monaco-editor/esm/vs/editor/editor.api"; import { useTheme, Box, BoxProps, AppBar, Toolbar } from "@mui/material"; @@ -72,6 +72,36 @@ export default function CodeEditor({ onValidate?.(markers); }; + const validate = (monaco: Monaco, model: editor.ITextModel) => { + const markers = []; + for (let i = 1; i < model.getLineCount() + 1; i++) { + const range = { + startLineNumber: i, + startColumn: 1, + endLineNumber: i, + endColumn: model.getLineLength(i) + 1, + }; + const line = model.getValueInRange(range); + for (const keyword of ["console.log", "console.warn", "console.error"]) { + const consoleLogIndex = line.indexOf(keyword); + if (consoleLogIndex >= 0) { + markers.push({ + message: `Replace with ${keyword.replace( + "console", + "logging" + )}: Rowy Cloud Logging provides a better experience to view logs. Simply replace 'console' with 'logging'. \n\nhttps://docs.rowy.io/cloud-logs`, + severity: monaco.MarkerSeverity.Warning, + startLineNumber: range.startLineNumber, + endLineNumber: range.endLineNumber, + startColumn: consoleLogIndex + 1, + endColumn: consoleLogIndex + keyword.length + 1, + }); + } + } + } + monaco.editor.setModelMarkers(model, "owner", markers); + }; + return ( { monaco.editor.defineTheme("github-light", githubLightTheme as any); monaco.editor.defineTheme("github-dark", githubDarkTheme as any); + monaco.editor.onDidCreateModel((model) => { + validate(monaco, model); + model.onDidChangeContent(() => { + validate(monaco, model); + }); + }); }} onMount={(editor) => { if (onFocus) editor.onDidFocusEditorWidget(onFocus); diff --git a/src/components/CodeEditor/CodeEditorHelper.tsx b/src/components/CodeEditor/CodeEditorHelper.tsx index 299c21347..caf3fbc05 100644 --- a/src/components/CodeEditor/CodeEditorHelper.tsx +++ b/src/components/CodeEditor/CodeEditorHelper.tsx @@ -9,6 +9,9 @@ import { projectScope, projectIdAtom } from "@src/atoms/projectScope"; export interface ICodeEditorHelperProps { docLink: string; + disableDefaultVariables?: boolean; + disableSecretManagerLink?: boolean; + disableCloudManagerLink?: boolean; additionalVariables?: { key: string; description: string; @@ -17,28 +20,37 @@ export interface ICodeEditorHelperProps { export default function CodeEditorHelper({ docLink, + disableDefaultVariables, + disableSecretManagerLink, + disableCloudManagerLink, additionalVariables, }: ICodeEditorHelperProps) { const [projectId] = useAtom(projectIdAtom, projectScope); - const availableVariables = [ - { - key: "db", - description: `db object provides access to firestore database instance of this project. giving you access to any collection or document in this firestore instance`, - }, - { - key: "auth", - description: `auth provides access to a firebase auth instance, can be used to manage auth users or generate tokens.`, - }, - { - key: "storage", - description: `firebase Storage can be accessed through this, storage.bucket() returns default storage bucket of the firebase project.`, - }, - { - key: "rowy", - description: `rowy provides a set of functions that are commonly used, such as easy file uploads & access to GCP Secret Manager`, - }, - ]; + const availableVariables = disableDefaultVariables + ? [] + : [ + { + key: "db", + description: `db object provides access to firestore database instance of this project. giving you access to any collection or document in this firestore instance`, + }, + { + key: "auth", + description: `auth provides access to a firebase auth instance, can be used to manage auth users or generate tokens.`, + }, + { + key: "storage", + description: `firebase Storage can be accessed through this, storage.bucket() returns default storage bucket of the firebase project.`, + }, + { + 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 ( - - - - - + {!disableSecretManagerLink && ( + + + + + + )} - - - - - + {!disableCloudManagerLink && ( + + + + + + )} void; + warn: (...payload: any[]) => void; + error: (...payload: any[]) => void; +}; interface Rowy { metadata: { /** diff --git a/src/components/CodeEditor/useMonacoCustomizations.ts b/src/components/CodeEditor/useMonacoCustomizations.ts index e6d555332..d145d72e6 100644 --- a/src/components/CodeEditor/useMonacoCustomizations.ts +++ b/src/components/CodeEditor/useMonacoCustomizations.ts @@ -1,9 +1,4 @@ import { useEffect } from "react"; -// import { -// quicktype, -// InputData, -// jsonInputForTargetLanguage, -// } from "quicktype-core"; import { useAtom } from "jotai"; import { @@ -13,15 +8,10 @@ import { } from "@src/atoms/tableScope"; import { useMonaco } from "@monaco-editor/react"; import type { languages } from "monaco-editor/esm/vs/editor/editor.api"; -import githubLightTheme from "./github-light-default.json"; -import githubDarkTheme from "./github-dark-default.json"; import { useTheme } from "@mui/material"; import type { SystemStyleObject, Theme } from "@mui/system"; -// TODO: -// import { getFieldType, getFieldProp } from "@src/components/fields"; - /* eslint-disable import/no-webpack-loader-syntax */ import firestoreDefs from "!!raw-loader!./firestore.d.ts"; import firebaseAuthDefs from "!!raw-loader!./firebaseAuth.d.ts"; @@ -72,7 +62,6 @@ export default function useMonacoCustomizations({ }; }, []); - // Initialize external libs & TypeScript compiler options useEffect(() => { if (!monaco) return; @@ -95,6 +84,8 @@ export default function useMonacoCustomizations({ "ts:filename/utils.d.ts" ); monaco.languages.typescript.javascriptDefaults.addExtraLib(rowyUtilsDefs); + + setLoggingReplacementActions(); } catch (error) { console.error( "An error occurred during initialization of Monaco: ", @@ -135,6 +126,52 @@ export default function useMonacoCustomizations({ } }, [monaco, stringifiedDiagnosticsOptions]); + const setLoggingReplacementActions = () => { + if (!monaco) return; + const { dispose } = monaco.languages.registerCodeActionProvider( + "javascript", + { + provideCodeActions: (model, range, context, token) => { + const actions = context.markers + .filter((error) => { + return error.message.includes("Rowy Cloud Logging"); + }) + .map((error) => { + // first sentence of the message is "Replace with logging.[log/warn/error]" + const firstSentence = error.message.split(":")[0]; + const replacement = firstSentence.split("with ")[1]; + return { + title: firstSentence, + diagnostics: [error], + kind: "quickfix", + edit: { + edits: [ + { + resource: model.uri, + edit: { + range: error, + text: replacement, + }, + }, + ], + }, + isPreferred: true, + }; + }); + return { + actions: actions, + dispose: () => {}, + }; + }, + } + ); + monaco.editor.onWillDisposeModel((model) => { + // dispose code action provider when model is disposed + // this makes sure code actions are not displayed multiple times + dispose(); + }); + }; + const addJsonFieldDefinition = async ( columnKey: string, interfaceName: string diff --git a/src/components/ColumnMenu/ColumnMenu.tsx b/src/components/ColumnMenu/ColumnMenu.tsx index cf0252171..dec3f6fe4 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"; @@ -62,6 +64,7 @@ import { } from "@src/utils/table"; import { runRoutes } from "@src/constants/runRoutes"; import { useSnackLogContext } from "@src/contexts/SnackLogContext"; +import useSaveTableSorts from "@src/components/Table/ColumnHeader/useSaveTableSorts"; export interface IMenuModalProps { name: string; @@ -107,11 +110,15 @@ 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); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const triggerSaveTableSorts = useSaveTableSorts(canEditColumns); + if (!columnMenu) return null; const { column, anchorEl } = columnMenu; if (column.type === FieldType.last) return null; @@ -185,6 +192,9 @@ export default function ColumnMenu({ setTableSorts( isSorted && !isAsc ? [] : [{ key: sortKey, direction: "desc" }] ); + if (!isSorted || isAsc) { + triggerSaveTableSorts([{ key: sortKey, direction: "desc" }]); + } handleClose(); }, active: isSorted && !isAsc, @@ -199,6 +209,9 @@ export default function ColumnMenu({ setTableSorts( isSorted && isAsc ? [] : [{ key: sortKey, direction: "asc" }] ); + if (!isSorted || !isAsc) { + triggerSaveTableSorts([{ key: sortKey, direction: "asc" }]); + } handleClose(); }, active: isSorted && isAsc, @@ -230,14 +243,24 @@ export default function ColumnMenu({ defaultQuery: { key: column.fieldName, operator: - getFieldProp("filter", column.type)!.operators[0]?.value || "==", + getFieldProp( + "filter", + column.type === FieldType.derivative + ? column.config?.renderFieldType + : column.type + )!.operators[0]?.value || "==", value: "", }, }); handleClose(); }, active: column.hidden, - disabled: !getFieldProp("filter", column.type), + disabled: !getFieldProp( + "filter", + column.type === FieldType.derivative + ? column.config?.renderFieldType + : column.type + ), }, ]; @@ -314,26 +337,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/ColumnConfig.tsx b/src/components/ColumnModals/ColumnConfigModal/ColumnConfig.tsx index 072c22e9f..276656068 100644 --- a/src/components/ColumnModals/ColumnConfigModal/ColumnConfig.tsx +++ b/src/components/ColumnModals/ColumnConfigModal/ColumnConfig.tsx @@ -50,8 +50,9 @@ export default function ColumnConfigModal({ const rendedFieldSettings = useMemo( () => - [FieldType.derivative, FieldType.aggregate].includes(column.type) && - newConfig.renderFieldType + [FieldType.derivative, FieldType.aggregate, FieldType.formula].includes( + column.type + ) && newConfig.renderFieldType ? getFieldProp("settings", newConfig.renderFieldType) : null, [newConfig.renderFieldType, column.type] diff --git a/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx b/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx index 5213eb8a7..5146c387b 100644 --- a/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx +++ b/src/components/ColumnModals/ColumnConfigModal/DefaultValueInput.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, createElement, useState } from "react"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import Checkbox from "@mui/material/Checkbox"; import FormControlLabel from "@mui/material/FormControlLabel"; @@ -17,6 +17,7 @@ import { projectScope, compatibleRowyRunVersionAtom, projectSettingsAtom, + rowyRunModalAtom, } from "@src/atoms/projectScope"; import { ColumnConfig } from "@src/types/table"; @@ -52,18 +53,23 @@ 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})=>{ - ${column.config?.defaultValue.script} - }`; + dynamicValueFn = `const dynamicValueFn: DefaultValue = async ({row,ref,db,storage,auth,logging})=>{ + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("dynamicValueFn started") + + ${column.config?.defaultValue.script} + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`; } else { - dynamicValueFn = `const dynamicValueFn : DefaultValue = async ({row,ref,db,storage,auth})=>{ - // Write your default value code here - // for example: - // generate random hex color - // const color = "#" + Math.floor(Math.random() * 16777215).toString(16); - // return color; - // checkout the documentation for more info: https://docs.rowy.io/how-to/default-values#dynamic - }`; + dynamicValueFn = `const dynamicValueFn: DefaultValue = async ({row,ref,db,storage,auth,logging})=>{ + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("dynamicValueFn started") + + // Example: generate random hex color + // const color = "#" + Math.floor(Math.random() * 16777215).toString(16); + // return color; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`; } return ( @@ -93,6 +99,7 @@ export default function DefaultValueInput({ column, }: IDefaultValueInputProps) { const [projectSettings] = useAtom(projectSettingsAtom, projectScope); + const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope); const _type = column.type !== FieldType.derivative @@ -152,9 +159,23 @@ export default function DefaultValueInput({ "Dynamic" ) : ( <> - Dynamic —{" "} + Dynamic{" "} - Requires Rowy Run setup + Requires + { + e.stopPropagation(); + openRowyRunModal({ feature: "Dynamic Default Value" }); + }} + > + Cloud Function + ) diff --git a/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts b/src/components/ColumnModals/ColumnConfigModal/defaultValue.d.ts index 0e50bb2d5..779bd9be2 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/ColumnModals/FieldsDropdown.tsx b/src/components/ColumnModals/FieldsDropdown.tsx index 8257292cb..529c3b840 100644 --- a/src/components/ColumnModals/FieldsDropdown.tsx +++ b/src/components/ColumnModals/FieldsDropdown.tsx @@ -1,16 +1,24 @@ import MultiSelect from "@rowy/multiselect"; -import { ListItemIcon } from "@mui/material"; +import { Box, ListItemIcon, Typography } from "@mui/material"; import { FIELDS } from "@src/components/fields"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; +import { useSetAtom, useAtom } from "jotai"; +import { + projectScope, + projectSettingsAtom, + rowyRunModalAtom, +} from "@src/atoms/projectScope"; + export interface IFieldsDropdownProps { value: FieldType | ""; onChange: (value: FieldType) => void; hideLabel?: boolean; label?: string; options?: FieldType[]; + [key: string]: any; } @@ -25,13 +33,21 @@ export default function FieldsDropdown({ options: optionsProp, ...props }: IFieldsDropdownProps) { + const [projectSettings] = useAtom(projectSettingsAtom, projectScope); + const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope); const fieldTypesToDisplay = optionsProp ? FIELDS.filter((fieldConfig) => optionsProp.indexOf(fieldConfig.type) > -1) : FIELDS; - const options = fieldTypesToDisplay.map((fieldConfig) => ({ - label: fieldConfig.name, - value: fieldConfig.type, - })); + const options = fieldTypesToDisplay.map((fieldConfig) => { + const requireCloudFunctionSetup = + fieldConfig.requireCloudFunction && !projectSettings.rowyRunUrl; + return { + label: fieldConfig.name, + value: fieldConfig.type, + disabled: requireCloudFunctionSetup, + requireCloudFunctionSetup, + }; + }); return ( getFieldProp("group", option.value), + ListboxProps: { + sx: { + '& li.MuiAutocomplete-option[aria-disabled="true"]': { + opacity: 1, + }, + '& li.MuiAutocomplete-option[aria-disabled="true"] > *': { + opacity: 0.4, + }, + '& li.MuiAutocomplete-option[aria-disabled="true"] > .require-cloud-function': + { + opacity: 1, + }, + }, + }, }, } as any)} itemRenderer={(option) => ( @@ -51,7 +81,33 @@ export default function FieldsDropdown({ {getFieldProp("icon", option.value as FieldType)} - {option.label} + {option.label} + {option.requireCloudFunctionSetup && ( + + {" "} + Requires + { + e.stopPropagation(); + openRowyRunModal({ feature: option.label }); + }} + > + Cloud Function + + + )} )} label={label || "Field type"} diff --git a/src/components/SteppedAccordion.tsx b/src/components/SteppedAccordion.tsx index 97a62f8d1..4d93d363c 100644 --- a/src/components/SteppedAccordion.tsx +++ b/src/components/SteppedAccordion.tsx @@ -92,7 +92,17 @@ export default function SteppedAccordion({ } {...labelButtonProps} > - + {title} {content && } diff --git a/src/components/Table/Breadcrumbs/BreadcrumbsTableRoot.tsx b/src/components/Table/Breadcrumbs/BreadcrumbsTableRoot.tsx index 676408e30..1e238e3dc 100644 --- a/src/components/Table/Breadcrumbs/BreadcrumbsTableRoot.tsx +++ b/src/components/Table/Breadcrumbs/BreadcrumbsTableRoot.tsx @@ -1,6 +1,6 @@ import { useAtom } from "jotai"; import { useParams, Link as RouterLink } from "react-router-dom"; -import { find, camelCase, uniq } from "lodash-es"; +import { find, camelCase } from "lodash-es"; import { Stack, @@ -12,13 +12,9 @@ import { } from "@mui/material"; import ReadOnlyIcon from "@mui/icons-material/EditOffOutlined"; -import InfoTooltip from "@src/components/InfoTooltip"; -import RenderedMarkdown from "@src/components/RenderedMarkdown"; - import { projectScope, userRolesAtom, - tableDescriptionDismissedAtom, tablesAtom, } from "@src/atoms/projectScope"; import { ROUTES } from "@src/constants/routes"; @@ -31,10 +27,6 @@ export default function BreadcrumbsTableRoot(props: StackProps) { const { id } = useParams(); const [userRoles] = useAtom(userRolesAtom, projectScope); - const [dismissed, setDismissed] = useAtom( - tableDescriptionDismissedAtom, - projectScope - ); const [tables] = useAtom(tablesAtom, projectScope); const tableSettings = find(tables, ["id", id]); @@ -83,28 +75,6 @@ export default function BreadcrumbsTableRoot(props: StackProps) { )} - - {tableSettings.description && ( - - - - } - buttonLabel="Table info" - tooltipProps={{ - componentsProps: { - popper: { sx: { zIndex: "appBar" } }, - tooltip: { sx: { maxWidth: "75vw" } }, - } as any, - }} - defaultOpen={!dismissed.includes(tableSettings.id)} - onClose={() => setDismissed((d) => uniq([...d, tableSettings.id]))} - /> - )} ); } diff --git a/src/components/Table/ColumnHeader/ColumnHeader.tsx b/src/components/Table/ColumnHeader/ColumnHeader.tsx index 40e5c5a15..9edec5449 100644 --- a/src/components/Table/ColumnHeader/ColumnHeader.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeader.tsx @@ -233,6 +233,7 @@ export const ColumnHeader = memo(function ColumnHeader({ sortKey={sortKey} currentSort={currentSort} tabIndex={focusInsideCell ? 0 : -1} + canEditColumns={canEditColumns} /> )} diff --git a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx index b9d1f6ac6..45e924f0d 100644 --- a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx @@ -9,6 +9,7 @@ import IconSlash, { } from "@src/components/IconSlash"; import { tableScope, tableSortsAtom } from "@src/atoms/tableScope"; +import useSaveTableSorts from "./useSaveTableSorts"; export const SORT_STATES = ["none", "desc", "asc"] as const; @@ -16,6 +17,7 @@ export interface IColumnHeaderSortProps { sortKey: string; currentSort: typeof SORT_STATES[number]; tabIndex?: number; + canEditColumns: boolean; } /** @@ -26,15 +28,24 @@ export const ColumnHeaderSort = memo(function ColumnHeaderSort({ sortKey, currentSort, tabIndex, + canEditColumns, }: IColumnHeaderSortProps) { const setTableSorts = useSetAtom(tableSortsAtom, tableScope); const nextSort = SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0]; + const triggerSaveTableSorts = useSaveTableSorts(canEditColumns); + const handleSortClick = () => { if (nextSort === "none") setTableSorts([]); else setTableSorts([{ key: sortKey, direction: nextSort }]); + triggerSaveTableSorts([ + { + key: sortKey, + direction: nextSort === "none" ? "asc" : nextSort, + }, + ]); }; return ( diff --git a/src/components/Table/ColumnHeader/useSaveTableSorts.tsx b/src/components/Table/ColumnHeader/useSaveTableSorts.tsx new file mode 100644 index 000000000..c0c239ed1 --- /dev/null +++ b/src/components/Table/ColumnHeader/useSaveTableSorts.tsx @@ -0,0 +1,98 @@ +import { useCallback, useState } from "react"; +import { useAtom } from "jotai"; +import { SnackbarKey, useSnackbar } from "notistack"; + +import LoadingButton from "@mui/lab/LoadingButton"; +import CheckIcon from "@mui/icons-material/Check"; + +import CircularProgressOptical from "@src/components/CircularProgressOptical"; +import { + tableIdAtom, + tableScope, + updateTableSchemaAtom, +} from "@src/atoms/tableScope"; +import { projectScope, updateUserSettingsAtom } from "@src/atoms/projectScope"; +import { TableSort } from "@src/types/table"; + +function useSaveTableSorts(canEditColumns: boolean) { + const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope); + const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope); + const [tableId] = useAtom(tableIdAtom, tableScope); + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const [snackbarId, setSnackbarId] = useState(null); + + // Offer to save when table sorts changes + const trigger = useCallback( + (sorts: TableSort[]) => { + if (!updateTableSchema) throw new Error("Cannot update table schema"); + if (updateUserSettings) { + updateUserSettings({ + tables: { + [`${tableId}`]: { sorts }, + }, + }); + } + if (!canEditColumns) return; + if (snackbarId) { + closeSnackbar(snackbarId); + } + setSnackbarId( + enqueueSnackbar("Apply this sorting for all users?", { + action: ( + await updateTableSchema({ sorts })} + /> + ), + anchorOrigin: { horizontal: "center", vertical: "top" }, + }) + ); + + return () => (snackbarId ? closeSnackbar(snackbarId) : null); + }, + [ + updateUserSettings, + canEditColumns, + snackbarId, + enqueueSnackbar, + tableId, + closeSnackbar, + updateTableSchema, + ] + ); + + return trigger; +} + +function SaveTableSortButton({ updateTable }: { updateTable: Function }) { + const [state, setState] = useState<"" | "loading" | "success" | "error">(""); + + const handleSaveToSchema = async () => { + setState("loading"); + try { + await updateTable(); + setState("success"); + } catch (e) { + setState("error"); + } + }; + + return ( + + ) : ( + + ) + } + > + Save + + ); +} + +export default useSaveTableSorts; diff --git a/src/components/Table/ContextMenu/BasicCellContextMenuActions.tsx b/src/components/Table/ContextMenu/BasicCellContextMenuActions.tsx index fdaa1557a..25b6ce0fd 100644 --- a/src/components/Table/ContextMenu/BasicCellContextMenuActions.tsx +++ b/src/components/Table/ContextMenu/BasicCellContextMenuActions.tsx @@ -1,94 +1,19 @@ -import { useAtom, useSetAtom } from "jotai"; -import { useSnackbar } from "notistack"; -import { get, find } from "lodash-es"; - -// import Cut from "@mui/icons-material/ContentCut"; import { Copy as CopyCells } from "@src/assets/icons"; +// import Cut from "@mui/icons-material/ContentCut"; import Paste from "@mui/icons-material/ContentPaste"; - -import { - tableScope, - tableSchemaAtom, - tableRowsAtom, - updateFieldAtom, -} from "@src/atoms/tableScope"; -import { getFieldProp, getFieldType } from "@src/components/fields"; import { IFieldConfig } from "@src/components/fields/types"; +import { useMenuAction } from "@src/components/Table/useMenuAction"; // TODO: Remove this and add `handlePaste` function to column config export const BasicContextMenuActions: IFieldConfig["contextMenuActions"] = ( selectedCell, reset ) => { - const { enqueueSnackbar } = useSnackbar(); - - const [tableSchema] = useAtom(tableSchemaAtom, tableScope); - const [tableRows] = useAtom(tableRowsAtom, tableScope); - const updateField = useSetAtom(updateFieldAtom, tableScope); - - const selectedCol = tableSchema.columns?.[selectedCell.columnKey]; - if (!selectedCol) return []; - - const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]); - const cellValue = get(selectedRow, selectedCol.fieldName); - const handleClose = async () => await reset?.(); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(cellValue); - enqueueSnackbar("Copied"); - } catch (error) { - enqueueSnackbar(`Failed to copy:${error}`, { variant: "error" }); - } - handleClose(); - }; - - // const handleCut = async () => { - // try { - // await navigator.clipboard.writeText(cellValue); - // if (typeof cellValue !== "undefined") - // updateField({ - // path: selectedCell.path, - // fieldName: selectedCol.fieldName, - // value: undefined, - // deleteField: true, - // }); - // } catch (error) { - // enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" }); - // } - // handleClose(); - // }; - - const handlePaste = async () => { - try { - if (!selectedCol) return; - const text = await navigator.clipboard.readText(); - const cellDataType = getFieldProp("dataType", getFieldType(selectedCol)); - let parsed; - switch (cellDataType) { - case "number": - parsed = Number(text); - if (isNaN(parsed)) throw new Error(`${text} is not a number`); - break; - case "string": - parsed = text; - break; - default: - parsed = JSON.parse(text); - break; - } - updateField({ - path: selectedCell.path, - fieldName: selectedCol.fieldName, - value: parsed, - }); - } catch (error) { - enqueueSnackbar(`Failed to paste: ${error}`, { variant: "error" }); - } - - handleClose(); - }; + const { handleCopy, handlePaste, cellValue } = useMenuAction( + selectedCell, + handleClose + ); const contextMenuActions = [ // { label: "Cut", icon: , onClick: handleCut }, diff --git a/src/components/Table/ContextMenu/MenuContents.tsx b/src/components/Table/ContextMenu/MenuContents.tsx index 5a10aaef7..88a973c29 100644 --- a/src/components/Table/ContextMenu/MenuContents.tsx +++ b/src/components/Table/ContextMenu/MenuContents.tsx @@ -192,7 +192,12 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { value: null, deleteField: true, }); - const columnFilters = getFieldProp("filter", selectedColumn?.type); + const columnFilters = getFieldProp( + "filter", + selectedColumn?.type === FieldType.derivative + ? selectedColumn.config?.renderFieldType + : selectedColumn?.type + ); const handleFilterValue = () => { openTableFiltersPopover({ defaultQuery: { diff --git a/src/components/Table/FinalColumn/FinalColumnHeader.tsx b/src/components/Table/FinalColumn/FinalColumnHeader.tsx index a1989f53d..3d65a9a41 100644 --- a/src/components/Table/FinalColumn/FinalColumnHeader.tsx +++ b/src/components/Table/FinalColumn/FinalColumnHeader.tsx @@ -1,9 +1,8 @@ -import { useAtom, useSetAtom } from "jotai"; +import { useSetAtom } from "jotai"; import { Box, BoxProps, Button } from "@mui/material"; import { AddColumn as AddColumnIcon } from "@src/assets/icons"; -import { projectScope, userRolesAtom } from "@src/atoms/projectScope"; import { tableScope, columnModalAtom } from "@src/atoms/tableScope"; import { spreadSx } from "@src/utils/ui"; @@ -17,10 +16,43 @@ export default function FinalColumnHeader({ canAddColumns, ...props }: IFinalColumnHeaderProps) { - const [userRoles] = useAtom(userRolesAtom, projectScope); const openColumnModal = useSetAtom(columnModalAtom, tableScope); - if (!userRoles.includes("ADMIN")) + if (canAddColumns) + return ( + `1px solid ${theme.palette.divider}`, + borderLeft: "none", + borderTopRightRadius: (theme) => theme.shape.borderRadius, + borderBottomRightRadius: (theme) => theme.shape.borderRadius, + display: "flex", + alignItems: "center", + width: 32 * 3 + 4 * 2 + 10 * 2, + overflow: "visible", + px: 0.75, + }, + ...spreadSx(props.sx), + ]} + className="column-header" + > + + + ); + else return ( ); - - return ( - `1px solid ${theme.palette.divider}`, - borderLeft: "none", - borderTopRightRadius: (theme) => theme.shape.borderRadius, - borderBottomRightRadius: (theme) => theme.shape.borderRadius, - - display: "flex", - alignItems: "center", - - width: 32 * 3 + 4 * 2 + 10 * 2, - overflow: "visible", - px: 0.75, - }, - ...spreadSx(props.sx), - ]} - className="column-header" - > - - - ); } diff --git a/src/components/Table/Styled/StyledCell.tsx b/src/components/Table/Styled/StyledCell.tsx index 5423e8e34..2d93245fe 100644 --- a/src/components/Table/Styled/StyledCell.tsx +++ b/src/components/Table/Styled/StyledCell.tsx @@ -19,6 +19,17 @@ export const StyledCell = styled("div")(({ theme }) => ({ alignItems: "center", }, + "& > .cell-contents-contain-none": { + padding: "0 var(--cell-padding)", + width: "100%", + height: "100%", + contain: "none", + overflow: "hidden", + + display: "flex", + alignItems: "center", + }, + backgroundColor: "var(--cell-background-color)", border: `1px solid ${theme.palette.divider}`, diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 092ff0abd..b81385832 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,5 +1,5 @@ import { useMemo, useRef, useState, useEffect, useCallback } from "react"; -import useStateRef from "react-usestateref"; +// import useStateRef from "react-usestateref"; // testing with useStateWithRef import { useAtom, useSetAtom } from "jotai"; import { useThrottledCallback } from "use-debounce"; import { @@ -30,11 +30,18 @@ import { tableNextPageAtom, tablePageAtom, updateColumnAtom, + selectedCellAtom, + tableSortsAtom, + tableIdAtom, } from "@src/atoms/tableScope"; +import { projectScope, userSettingsAtom } from "@src/atoms/projectScope"; import { getFieldType, getFieldProp } from "@src/components/fields"; import { useKeyboardNavigation } from "./useKeyboardNavigation"; +import { useMenuAction } from "./useMenuAction"; import { useSaveColumnSizing } from "./useSaveColumnSizing"; +import useHotKeys from "./useHotKey"; import type { TableRow, ColumnConfig } from "@src/types/table"; +import useStateWithRef from "./useStateWithRef"; // testing with useStateWithRef export const DEFAULT_ROW_HEIGHT = 41; export const DEFAULT_COL_WIDTH = 150; @@ -95,11 +102,18 @@ export default function Table({ const updateColumn = useSetAtom(updateColumnAtom, tableScope); + // Get user settings and tableId for applying sort sorting + const [userSettings] = useAtom(userSettingsAtom, projectScope); + const [tableId] = useAtom(tableIdAtom, tableScope); + const setTableSorts = useSetAtom(tableSortsAtom, tableScope); + // Store a **state** and reference to the container element // so the state can re-render `TableBody`, preventing virtualization // not detecting scroll if the container element was initially `null` const [containerEl, setContainerEl, containerRef] = - useStateRef(null); + // useStateRef(null); // <-- older approach with useStateRef + useStateWithRef(null); // <-- newer approach with custom hook + const gridRef = useRef(null); // Get column defs from table schema @@ -181,6 +195,13 @@ export default function Table({ tableRows, leafColumns, }); + const [selectedCell] = useAtom(selectedCellAtom, tableScope); + const { handleCopy, handlePaste, handleCut } = useMenuAction(selectedCell); + const { handler: hotKeysHandler } = useHotKeys([ + ["mod+C", handleCopy], + ["mod+X", handleCut], + ["mod+V", handlePaste], + ]); // Handle prompt to save local column sizes if user `canEditColumns` useSaveColumnSizing(columnSizing, canEditColumns); @@ -223,9 +244,24 @@ export default function Table({ containerRef, ]); + // apply user default sort on first render + const [applySort, setApplySort] = useState(true); + useEffect(() => { + if (applySort && Object.keys(tableSchema).length) { + const userDefaultSort = userSettings.tables?.[tableId]?.sorts || []; + setTableSorts( + userDefaultSort.length ? userDefaultSort : tableSchema.sorts || [] + ); + setApplySort(false); + } + }, [tableSchema, userSettings, tableId, setTableSorts, applySort]); + return (
setContainerEl(el)} + ref={(el) => { + if (!el) return; + setContainerEl(el); + }} onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} style={{ overflow: "auto", width: "100%", height: "100%" }} > @@ -242,7 +278,10 @@ export default function Table({ "--row-height": `${tableSchema.rowHeight || DEFAULT_ROW_HEIGHT}px`, } as any } - onKeyDown={handleKeyDown} + onKeyDown={(e) => { + handleKeyDown(e); + hotKeysHandler(e); + }} >
{ @@ -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/Table/TableCell/withRenderTableCell.tsx b/src/components/Table/TableCell/withRenderTableCell.tsx index 60935e07a..eb31eb3c4 100644 --- a/src/components/Table/TableCell/withRenderTableCell.tsx +++ b/src/components/Table/TableCell/withRenderTableCell.tsx @@ -164,6 +164,7 @@ export default function withRenderTableCell(
); + if (disabled || (editorMode !== "inline" && !focusInsideCell)) return displayCell; @@ -191,7 +192,7 @@ export default function withRenderTableCell( if (editorMode === "inline") { return (
diff --git a/src/components/Table/useHotKey.tsx b/src/components/Table/useHotKey.tsx new file mode 100644 index 000000000..cf74808e9 --- /dev/null +++ b/src/components/Table/useHotKey.tsx @@ -0,0 +1,99 @@ +import { useCallback } from "react"; + +type HotKeysAction = [ + string, + (event: React.KeyboardEvent | KeyboardEvent) => void +]; + +export default function useHotKeys(actions: HotKeysAction[]) { + // master event handler + const handler = useCallback( + (event: React.KeyboardEvent) => { + const event_ = "nativeEvent" in event ? event.nativeEvent : event; + actions.forEach(([hotkey, handler_]) => { + if (getHotkeyMatcher(hotkey)(event_)) { + event.preventDefault(); + handler_(event_); + } + }); + }, + [actions] + ); + + return { handler } as const; +} + +type KeyboardModifiers = { + alt: boolean; + ctrl: boolean; + meta: boolean; + mod: boolean; + shift: boolean; +}; + +export type Hotkey = KeyboardModifiers & { + key?: string; +}; +function isExactHotkey(hotkey: Hotkey, event: KeyboardEvent): boolean { + const { alt, ctrl, meta, mod, shift, key } = hotkey; + const { altKey, ctrlKey, metaKey, shiftKey, key: pressedKey } = event; + + if (alt !== altKey) { + return false; + } + + if (mod) { + if (!ctrlKey && !metaKey) { + return false; + } + } else { + if (ctrl !== ctrlKey) { + return false; + } + if (meta !== metaKey) { + return false; + } + } + if (shift !== shiftKey) { + return false; + } + + if ( + key && + (pressedKey.toLowerCase() === key.toLowerCase() || + event.code.replace("Key", "").toLowerCase() === key.toLowerCase()) + ) { + return true; + } + + return false; +} + +type CheckHotkeyMatch = (event: KeyboardEvent) => boolean; +export function getHotkeyMatcher(hotkey: string): CheckHotkeyMatch { + return (event) => isExactHotkey(parseHotkey(hotkey), event); +} + +function parseHotkey(hotkey: string): Hotkey { + const keys = hotkey + .toLowerCase() + .split("+") + .map((part) => part.trim()); + + const modifiers: KeyboardModifiers = { + alt: keys.includes("alt"), + ctrl: keys.includes("ctrl"), + meta: keys.includes("meta"), + mod: keys.includes("mod"), + shift: keys.includes("shift"), + }; + + const reservedKeys = ["alt", "ctrl", "meta", "shift", "mod"]; + + const freeKey = keys.find((key) => !reservedKeys.includes(key)); + + return { + ...modifiers, + key: freeKey, + }; +} diff --git a/src/components/Table/useMenuAction.tsx b/src/components/Table/useMenuAction.tsx new file mode 100644 index 000000000..516124d3c --- /dev/null +++ b/src/components/Table/useMenuAction.tsx @@ -0,0 +1,161 @@ +import { useCallback, useState, useEffect } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import { get, find } from "lodash-es"; + +import { + tableScope, + tableSchemaAtom, + tableRowsAtom, + updateFieldAtom, + SelectedCell, +} from "@src/atoms/tableScope"; +import { getFieldProp, getFieldType } from "@src/components/fields"; +import { ColumnConfig } from "@src/types/table"; + +import { FieldType } from "@src/constants/fields"; + +const SUPPORTED_TYPES = new Set([ + FieldType.shortText, + FieldType.longText, + FieldType.number, + FieldType.email, + FieldType.percentage, + FieldType.phone, + FieldType.richText, + FieldType.url, + FieldType.json, +]); + +export function useMenuAction( + selectedCell: SelectedCell | null, + handleClose?: Function +) { + const { enqueueSnackbar } = useSnackbar(); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [tableRows] = useAtom(tableRowsAtom, tableScope); + const updateField = useSetAtom(updateFieldAtom, tableScope); + const [cellValue, setCellValue] = useState(); + const [selectedCol, setSelectedCol] = useState(); + + const handleCopy = useCallback(async () => { + try { + if (cellValue !== undefined && cellValue !== null && cellValue !== "") { + await navigator.clipboard.writeText( + typeof cellValue === "object" ? JSON.stringify(cellValue) : cellValue + ); + enqueueSnackbar("Copied"); + } else { + await navigator.clipboard.writeText(""); + } + } catch (error) { + enqueueSnackbar(`Failed to copy:${error}`, { variant: "error" }); + } + if (handleClose) handleClose(); + }, [cellValue, enqueueSnackbar, handleClose]); + + const handleCut = useCallback(async () => { + try { + if (!selectedCell || !selectedCol || !cellValue) return; + if (cellValue !== undefined && cellValue !== null && cellValue !== "") { + await navigator.clipboard.writeText( + typeof cellValue === "object" ? JSON.stringify(cellValue) : cellValue + ); + enqueueSnackbar("Copied"); + } else { + await navigator.clipboard.writeText(""); + } + if (cellValue !== undefined) + updateField({ + path: selectedCell.path, + fieldName: selectedCol.fieldName, + value: undefined, + deleteField: true, + }); + } catch (error) { + enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" }); + } + if (handleClose) handleClose(); + }, [ + cellValue, + selectedCell, + selectedCol, + updateField, + enqueueSnackbar, + handleClose, + ]); + + const handlePaste = useCallback(async () => { + try { + if (!selectedCell || !selectedCol) return; + let text; + try { + text = await navigator.clipboard.readText(); + } catch (e) { + enqueueSnackbar(`Read clilboard permission denied.`, { + variant: "error", + }); + return; + } + const cellDataType = getFieldProp("dataType", getFieldType(selectedCol)); + let parsed; + switch (cellDataType) { + case "number": + parsed = Number(text); + if (isNaN(parsed)) throw new Error(`${text} is not a number`); + break; + case "string": + parsed = text; + break; + default: + parsed = JSON.parse(text); + break; + } + updateField({ + path: selectedCell.path, + fieldName: selectedCol.fieldName, + value: parsed, + }); + } catch (error) { + enqueueSnackbar( + `${selectedCol?.type} field does not support the data type being pasted`, + { variant: "error" } + ); + } + if (handleClose) handleClose(); + }, [selectedCell, selectedCol, updateField, enqueueSnackbar, handleClose]); + + useEffect(() => { + if (!selectedCell) return setCellValue(""); + const selectedCol = tableSchema.columns?.[selectedCell.columnKey]; + if (!selectedCol) return setCellValue(""); + setSelectedCol(selectedCol); + const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]); + setCellValue(get(selectedRow, selectedCol.fieldName)); + }, [selectedCell, tableSchema, tableRows]); + + const checkEnabled = useCallback( + (func: Function) => { + return function () { + if (SUPPORTED_TYPES.has(selectedCol?.type)) { + return func(); + } else { + enqueueSnackbar( + `${selectedCol?.type} field cannot be copied using keyboard shortcut`, + { + variant: "info", + } + ); + } + }; + }, + [selectedCol] + ); + + return { + handleCopy: checkEnabled(handleCopy), + handleCut: checkEnabled(handleCut), + handlePaste: handlePaste, + cellValue, + }; +} diff --git a/src/components/Table/useStateWithRef.ts b/src/components/Table/useStateWithRef.ts new file mode 100644 index 000000000..be6309779 --- /dev/null +++ b/src/components/Table/useStateWithRef.ts @@ -0,0 +1,29 @@ +import { + MutableRefObject, + useCallback, + useRef, + useSyncExternalStore, +} from "react"; + +// NOTE: This is not the final solution. But is a potential solution for this problem. +export default function useStateWithRef( + initialState: T +): [T, (newValue: T) => void, MutableRefObject] { + const value = useRef(initialState); + const get = useCallback(() => value.current, []); + const subscribers = useRef(new Set<() => void>()); + + const set = useCallback((newValue: T) => { + value.current = newValue; + subscribers.current.forEach((callback) => callback()); + }, []); + + const subscribe = useCallback((callback: () => void) => { + subscribers.current.add(callback); + return () => subscribers.current.delete(callback); + }, []); + + const state = useSyncExternalStore(subscribe, get); + + return [state, set, value]; +} diff --git a/src/components/Table/useTraceUpdates.ts b/src/components/Table/useTraceUpdates.ts new file mode 100644 index 000000000..9485c28ae --- /dev/null +++ b/src/components/Table/useTraceUpdates.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from "react"; + +// This hook is used to log changes to props in a component. +export default function useTraceUpdates( + props: { [key: string]: any }, + printMessage: string = "Changed props:" +) { + const prev = useRef(props); + useEffect(() => { + const changedProps = Object.entries(props).reduce((ps, [k, v]) => { + if (prev.current[k] !== v) { + // @ts-ignore + ps[k] = [prev.current[k], v]; + } + return ps; + }, {}); + if (Object.keys(changedProps).length > 0) { + console.log(printMessage, changedProps); + } + prev.current = props; + }); +} diff --git a/src/components/TableModals/CloudLogsModal/CloudLogItem.tsx b/src/components/TableModals/CloudLogsModal/CloudLogItem.tsx index f82a75f17..9e0887f35 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 5e5382eec..93093d38a 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 0cf0dc6ea..f8413f659 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 e69acc446..cc6775529 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,14 @@ import { TextField, InputAdornment, Button, + Box, + CircularProgress, + Alert, + Link, } 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 +28,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 +46,8 @@ import { cloudLogFiltersAtom, } from "@src/atoms/tableScope"; import { cloudLogFetcher } from "./utils"; +import { FieldType } from "@src/constants/fields"; +import { WIKI_LINKS } from "@src/constants/externalLinks"; export default function CloudLogsModal({ onClose }: ITableModalProps) { const [projectId] = useAtom(projectIdAtom, projectScope); @@ -92,7 +102,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 +120,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" && ( - + {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} + {["extension", "webhook", "column"].includes( + cloudLogFilters.type + ) && ( + + Remember to use logging functions,{" "} + log,warning,error for them to appear in the logs + bellow{" "} + + Learn more + + )} - - ) : isValidating ? ( - - ) : ( - + + {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 b6da74a89..9450348a1 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 dbd63a9e1..a0d5b82f4 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 extensions are very flexible you can do anything from updating other documents in your database, to making an api request to 3rd party service. + task: `const extensionBody: TaskBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") - // example: - // we can post notification to different discord channels based on row data + // Import any NPM package needed + // const vision = require('@google-cloud/vision'); + + // Task Extension is very flexible, you can do anything. + // From updating other documents in your database, to making an api request to 3rd party service. + // Example: 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) + 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) + }) */ + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref}) => { - // feel free to add your own code logic here - + docSync: `const extensionBody: DocSyncBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") + 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 }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref}) => { - // feel free to add your own code logic here + historySnapshot: `const extensionBody: HistorySnapshotBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") return ({ trackedFields: [], // a list of string of column names collectionId: "historySnapshots", // optionally change the sub-collection id of where the history snapshots are stored }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref}) => { - // feel free to add your own code logic here + algoliaIndex: `const extensionBody: AlgoliaIndexBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") return ({ fieldsToSync: [], // a list of string of column names @@ -113,29 +123,38 @@ const extensionBodyTemplate = { index: "", // algolia index to sync to objectID: ref.id, // algolia object ID, ref.id is one possible choice }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref}) => { - // feel free to add your own code logic here - + meiliIndex: `const extensionBody: MeiliIndexBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") + 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 }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref}) => { - // feel free to add your own code logic here - + bigqueryIndex: `const extensionBody: BigqueryIndexBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") + 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 }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref}) => { - // feel free to add your own code logic here + slackMessage: `const extensionBody: SlackMessageBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") + + // Import any NPM package needed + // const lodash = require('lodash'); return ({ channels: [], // a list of slack channel IDs in string @@ -143,18 +162,23 @@ const extensionBodyTemplate = { text: "", // the text parameter to pass in to slack api attachments: [], // the attachments parameter to pass in to slack api }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref}) => { - // feel free to add your own code logic here + sendgridEmail: `const extensionBody: SendgridEmailBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") + + // Import any NPM package needed + // const lodash = require('lodash'); return ({ from: "Name", // send from field personalizations: [ - { - to: [{ name: "", email: "" }], // recipient - dynamic_template_data: { - }, // template parameters - }, + { + to: [{ name: "", email: "" }], // recipient + dynamic_template_data: { + }, // template parameters + }, ], template_id: "", // sendgrid template ID categories: [], // helper info to categorise sendgrid emails @@ -163,9 +187,14 @@ const extensionBodyTemplate = { // add any other custom args you want to pass to sendgrid events here }, }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref}) => { - // feel free to add your own code logic here + apiCall: `const extensionBody: ApiCallBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") + + // Import any NPM package needed + // const lodash = require('lodash'); return ({ body: "", @@ -173,56 +202,64 @@ const extensionBodyTemplate = { method: "", callback: ()=>{}, }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref}) => { - /** - * - * Setup twilio secret key: https://docs.rowy.io/extensions/twilio-message#secret-manager-setup - * - * You can add any code logic here to be able to customize your message - * or dynamically get the from or to numbers - * - **/ + twilioMessage: `const extensionBody: TwilioMessageBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") + + // Import any NPM package needed + // const lodash = require('lodash'); + // Setup twilio secret key: https://docs.rowy.io/extensions/twilio-message#secret-manager-setup + // Add any code here to customize your message or dynamically get the from/to numbers return ({ from: "", // from phone number registered on twilio to: "", // recipient phone number - eg: row. body: "Hi there!" // message text }) + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, - pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref}) => { - // you can FCM token from the row or from the user document in the database + pushNotification: `const extensionBody: PushNotificationBody = async({row, db, change, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("extensionBody started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + // You can use FCM token from the row or from the user document in the database // const FCMtoken = row.FCMtoken - // or push through topic + // Or push through topic const topicName = 'industry-tech'; - // you can return single or array of notification payloads - return [{ - notification: { - title: 'Hello!', - }, - android: { + // You can return single or array of notification payloads + return [{ notification: { - imageUrl: 'https://thiscatdoesnotexist.com/' - } - }, - apns: { - payload: { - aps: { - 'mutable-content': 1 + title: 'Hello!', + }, + android: { + notification: { + imageUrl: 'https://thiscatdoesnotexist.com/' } }, - fcm_options: { - image: 'https://thiscatdoesnotexist.com/' - } - }, - webpush: { - headers: { - image: 'https://thiscatdoesnotexist.com/' - } - }, - // topic: topicName, // add topic send to subscribers - // token: FCMtoken // add FCM token to send to specific user -}] + apns: { + payload: { + aps: { + 'mutable-content': 1 + } + }, + fcm_options: { + image: 'https://thiscatdoesnotexist.com/' + } + }, + webpush: { + headers: { + image: 'https://thiscatdoesnotexist.com/' + } + }, + // topic: topicName, // add topic send to subscribers + // token: FCMtoken // add FCM token to send to specific user + }] + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, }; @@ -232,19 +269,23 @@ export function emptyExtensionObject( ): IExtension { return { name: `${type} extension`, - active: false, + active: true, 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 + conditions: `const condition: Condition = async({row, change, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("condition started") + return true; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, lastEditor: user, }; } + export function sparkToExtensionObjects( sparkConfig: string, user: IExtensionEditor diff --git a/src/components/TableModals/ImportAirtableWizard/ImportAirtableWizard.tsx b/src/components/TableModals/ImportAirtableWizard/ImportAirtableWizard.tsx index afe95479d..7a6cee3f3 100644 --- a/src/components/TableModals/ImportAirtableWizard/ImportAirtableWizard.tsx +++ b/src/components/TableModals/ImportAirtableWizard/ImportAirtableWizard.tsx @@ -72,7 +72,7 @@ export default function ImportAirtableWizard({ onClose }: ITableModalProps) { const newColumns = uniqBy( [...prev.newColumns, ...(value.newColumns ?? [])], "key" - ).filter((col) => pairs.some((pair) => pair.columnKey === col.key)); + ).filter((col) => pairs?.some((pair) => pair.columnKey === col.key)); return { ...prev, pairs, newColumns }; }); }, []); diff --git a/src/components/TableModals/ImportAirtableWizard/Step1Columns.tsx b/src/components/TableModals/ImportAirtableWizard/Step1Columns.tsx index 062355458..befaeedbc 100644 --- a/src/components/TableModals/ImportAirtableWizard/Step1Columns.tsx +++ b/src/components/TableModals/ImportAirtableWizard/Step1Columns.tsx @@ -57,6 +57,9 @@ export default function Step1Columns({ config.pairs.map((pair) => pair.fieldKey) ); + + const fieldKeys = Object.keys(airtableData.records[0].fields); + // When a field is selected to be imported const handleSelect = (field: string) => (e: React.ChangeEvent) => { @@ -123,6 +126,47 @@ export default function Step1Columns({ } }; + const handleSelectAll = () => { + if (selectedFields.length !== fieldKeys.length) { + setSelectedFields(fieldKeys) + fieldKeys.forEach(field => { + // Try to match each field to a column in the table + const match = + find(tableColumns, (column) => + column.label.toLowerCase().includes(field.toLowerCase()) + )?.value ?? null; + + const columnKey = camelCase(field); + const columnConfig: Partial = { + pairs: [], + newColumns: [], + }; + columnConfig.pairs = [ + { fieldKey: field, columnKey: match ?? columnKey }, + ]; + if (!match) { + columnConfig.newColumns = [ + { + name: field, + fieldName: columnKey, + key: columnKey, + type: + suggestType(airtableData.records, field) || FieldType.shortText, + index: -1, + config: {}, + }, + ]; + } + updateConfig(columnConfig); + }) + } else { + setSelectedFields([]) + setConfig((config) => ({ ...config, newColumns: [], pairs: [] })) + } + + }; + + // When a field is mapped to a new column const handleChange = (fieldKey: string) => (value: string) => { if (!value) return; @@ -159,7 +203,6 @@ export default function Step1Columns({ } }; - const fieldKeys = Object.keys(airtableData.records[0].fields); return (
@@ -180,14 +223,36 @@ export default function Step1Columns({ +
  • + + } + label={selectedFields.length == fieldKeys.length ? "Clear all" : "Select all"} + sx={{ + height: 42, + mr: 0, + alignItems: "center", + "& .MuiFormControlLabel-label": { mt: 0, flex: 1 }, + }} + /> +
  • {fieldKeys.map((field) => { const selected = selectedFields.indexOf(field) > -1; const columnKey = find(config.pairs, { fieldKey: field })?.columnKey ?? null; const matchingColumn = columnKey ? tableSchema.columns?.[columnKey] ?? - find(config.newColumns, { key: columnKey }) ?? - null + find(config.newColumns, { key: columnKey }) ?? + null : null; const isNewColumn = !!find(config.newColumns, { key: columnKey }); return ( diff --git a/src/components/TableModals/ImportCsvWizard/ImportCsvWizard.tsx b/src/components/TableModals/ImportCsvWizard/ImportCsvWizard.tsx index ac67337ea..b40e0e8c9 100644 --- a/src/components/TableModals/ImportCsvWizard/ImportCsvWizard.tsx +++ b/src/components/TableModals/ImportCsvWizard/ImportCsvWizard.tsx @@ -80,7 +80,7 @@ export default function ImportCsvWizard({ onClose }: ITableModalProps) { const newColumns = uniqBy( [...prev.newColumns, ...(value.newColumns ?? [])], "key" - ).filter((col) => pairs.some((pair) => pair.columnKey === col.key)); + ).filter((col) => pairs?.some((pair) => pair.columnKey === col.key)); return { ...prev, pairs, newColumns }; }); diff --git a/src/components/TableModals/ImportCsvWizard/Step1Columns.tsx b/src/components/TableModals/ImportCsvWizard/Step1Columns.tsx index 684584ec9..c2b04cd8e 100644 --- a/src/components/TableModals/ImportCsvWizard/Step1Columns.tsx +++ b/src/components/TableModals/ImportCsvWizard/Step1Columns.tsx @@ -64,6 +64,38 @@ export default function Step1Columns({ config.pairs.map((pair) => pair.csvKey) ); + const handleSelectAll = () => { + if (selectedFields.length !== csvData.columns.length) { + setSelectedFields(csvData.columns); + csvData.columns.forEach(field => { + // Try to match each field to a column in the table + const match = + find(tableColumns, (column) => + column.label.toLowerCase().includes(field.toLowerCase()) + )?.value ?? null; + const columnKey = camelCase(field); + const columnConfig: Partial = { pairs: [], newColumns: [] }; + columnConfig.pairs = [{ csvKey: field, columnKey: match ?? columnKey }]; + if (!match) { + columnConfig.newColumns = [ + { + name: field, + fieldName: columnKey, + key: columnKey, + type: suggestType(csvData.rows, field) || FieldType.shortText, + index: -1, + config: {}, + }, + ]; + } + updateConfig(columnConfig); + }) + } else { + setSelectedFields([]) + setConfig((config) => ({ ...config, newColumns: [], pairs: [] })) + } + }; + // When a field is selected to be imported const handleSelect = (field: string) => (e: React.ChangeEvent) => { @@ -187,14 +219,36 @@ export default function Step1Columns({ +
  • + + } + label={selectedFields.length == csvData.columns.length ? "Clear all" : "Select all"} + sx={{ + height: 42, + mr: 0, + alignItems: "center", + "& .MuiFormControlLabel-label": { mt: 0, flex: 1 }, + }} + /> +
  • {csvData.columns.map((field) => { const selected = selectedFields.indexOf(field) > -1; const columnKey = find(config.pairs, { csvKey: field })?.columnKey ?? null; const matchingColumn = columnKey ? tableSchema.columns?.[columnKey] ?? - find(config.newColumns, { key: columnKey }) ?? - null + find(config.newColumns, { key: columnKey }) ?? + null : null; const isNewColumn = !!find(config.newColumns, { key: columnKey }); diff --git a/src/components/TableModals/ImportExistingWizard/Step1Columns.tsx b/src/components/TableModals/ImportExistingWizard/Step1Columns.tsx index 29d5903fd..c5d832996 100644 --- a/src/components/TableModals/ImportExistingWizard/Step1Columns.tsx +++ b/src/components/TableModals/ImportExistingWizard/Step1Columns.tsx @@ -117,7 +117,7 @@ export default function Step1Columns({ config, setConfig }: IStepProps) { color="default" /> } - label="Select all" + label={selectedFields.length == allFields.length ? "Clear all" : "Select all"} sx={{ height: 42, mr: 0, diff --git a/src/components/TableModals/ImportExistingWizard/utils.ts b/src/components/TableModals/ImportExistingWizard/utils.ts index 457504207..d855da032 100644 --- a/src/components/TableModals/ImportExistingWizard/utils.ts +++ b/src/components/TableModals/ImportExistingWizard/utils.ts @@ -37,12 +37,24 @@ export const REGEX_URL = export const REGEX_HTML = /<\/?[a-z][\s\S]*>/; const inferTypeFromValue = (value: any) => { + // by default the type of value is string, so trying to convert it to JSON/Object. + try { + value = JSON.parse(value); + } catch (e) {} if (!value || typeof value === "function") return; if (Array.isArray(value) && typeof value[0] === "string") return FieldType.multiSelect; if (typeof value === "boolean") return FieldType.checkbox; if (isDate(value)) return FieldType.dateTime; + // trying to convert the value to date + if (typeof value !== "number" && +new Date(value)) { + // date and time are separated by a blank space, checking if time present. + if (value.split(" ").length > 1) { + return FieldType.dateTime; + } + return FieldType.date; + } if (typeof value === "object") { if ("hex" in value && "rgb" in value) return FieldType.color; @@ -71,6 +83,7 @@ const inferTypeFromValue = (value: any) => { export const suggestType = (data: { [key: string]: any }[], field: string) => { const results: Record = {}; + // console.log(data) data.forEach((row) => { const result = inferTypeFromValue(row[field]); if (!result) return; diff --git a/src/components/TableModals/WebhooksModal/AddWebhookButton.tsx b/src/components/TableModals/WebhooksModal/AddWebhookButton.tsx index 0d8a63d5d..64c2f15d3 100644 --- a/src/components/TableModals/WebhooksModal/AddWebhookButton.tsx +++ b/src/components/TableModals/WebhooksModal/AddWebhookButton.tsx @@ -48,7 +48,7 @@ export default function AddWebhookButton({ }} {...props} > - Add webhook… + Add Webhook… 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,41 +64,42 @@ 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}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("basicParser started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + // Optionally return an object to be added as a new row to the table + // Example: add the webhook body as row + const {body} = req; + ${ + table.audit !== false + ? `const ${ + table.auditFieldCreatedBy ?? "_createdBy" + } = await rowy.metadata.serviceAccountUser() + return { + ...body, + ${table.auditFieldCreatedBy ?? "_createdBy"} + }` + : `return body;` + } + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION 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; - }`, + ) => `const condition: Condition = async({ref, req, db, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("condition started") + + return true; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`, }, auth: ( webhookObject: IWebhook, diff --git a/src/components/TableModals/WebhooksModal/Schemas/sendgrid.tsx b/src/components/TableModals/WebhooksModal/Schemas/sendgrid.tsx index b557dd786..ea24dabe1 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/sendgrid.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/sendgrid.tsx @@ -13,32 +13,43 @@ export const webhookSendgrid = { 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) - };`, + ) => `const sendgridParser: Parser = async ({ req, db, ref, logging }) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("sendgridParser started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + 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)) { + // Multiple events are passed in one call + await Promise.allSettled(body.map(eventHandler)) + } else { + eventHandler(body) + } + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION 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; - }`, + ) => `const condition: Condition = async({ref, req, db, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("condition started") + + return true; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`, }, auth: ( webhookObject: IWebhook, diff --git a/src/components/TableModals/WebhooksModal/Schemas/stripe.tsx b/src/components/TableModals/WebhooksModal/Schemas/stripe.tsx index 31eb4aff0..bbf8c5a5f 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/stripe.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/stripe.tsx @@ -17,16 +17,23 @@ export const webhookStripe = { extraLibs: null, template: ( table: TableSettings - ) => `const sendgridParser: Parser = async ({ req, db, ref }) => { - const event = req.body - switch (event.type) { - case "payment_intent.succeeded": - break; - case "payment_intent.payment_failed": - break; - default: - // all other types - } + ) => `const stripeParser: Parser = async ({ req, db, ref, logging }) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("stripeParser started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + const event = req.body + switch (event.type) { + case "payment_intent.succeeded": + break; + case "payment_intent.payment_failed": + break; + default: + // All other types + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +} };`, }, condition: { @@ -34,9 +41,12 @@ export const webhookStripe = { extraLibs: null, template: ( table: TableSettings - ) => `const condition: Condition = async({ref,req,db}) => { - // feel free to add your own code logic here + ) => `const condition: Condition = async({ref, req, db, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("condition started") + return true; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY }`, }, auth: ( diff --git a/src/components/TableModals/WebhooksModal/Schemas/typeform.tsx b/src/components/TableModals/WebhooksModal/Schemas/typeform.tsx index 5fd4ce7dd..6fb1b6834 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/typeform.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/typeform.tsx @@ -13,70 +13,75 @@ export const webhookTypeform = { 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 - ` - } - };`, + ) => `const typeformParser: Parser = async({req, db, ref, logging}) =>{ + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("typeformParser started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + // This reduces the form submission into a single object of key value pairs + // Example: {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 + ? `const ${ + table.auditFieldCreatedBy ?? "_createdBy" + } = await rowy.metadata.serviceAccountUser() + return { + ...submission, + ${table.auditFieldCreatedBy ?? "_createdBy"} + }` + : `return submission;` + } + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION 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; - }`, + ) => `const condition: Condition = async({ref, req, db, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("condition started") + + return true; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`, }, auth: ( webhookObject: IWebhook, diff --git a/src/components/TableModals/WebhooksModal/Schemas/webform.tsx b/src/components/TableModals/WebhooksModal/Schemas/webform.tsx index bf5e8cdad..bcc1b0281 100644 --- a/src/components/TableModals/WebhooksModal/Schemas/webform.tsx +++ b/src/components/TableModals/WebhooksModal/Schemas/webform.tsx @@ -14,41 +14,42 @@ export const webhook = { 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; - ` - } - - }`, + ) => `const formParser: Parser = async({req, db, ref, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("formParser started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + // Optionally return an object to be added as a new row to the table + // Example: add the webhook body as row + const {body} = req; + ${ + table.audit !== false + ? `const ${ + table.auditFieldCreatedBy ?? "_createdBy" + } = await rowy.metadata.serviceAccountUser() + return { + ...body, + ${table.auditFieldCreatedBy ?? "_createdBy"} + }` + : `return body;` + } + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION 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; - }`, + ) => `const condition: Condition = async({ref, req, db, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("condition started") + + return true; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`, }, auth: ( webhookObject: IWebhook, diff --git a/src/components/TableModals/WebhooksModal/WebhookModal.tsx b/src/components/TableModals/WebhooksModal/WebhookModal.tsx index 8575c0b5c..01c2f2901 100644 --- a/src/components/TableModals/WebhooksModal/WebhookModal.tsx +++ b/src/components/TableModals/WebhooksModal/WebhookModal.tsx @@ -86,7 +86,7 @@ export default function WebhookModal({ disableBackdropClick disableEscapeKeyDown fullWidth - title={`${mode === "add" ? "Add" : "Update"} webhook: ${ + title={`${mode === "add" ? "Add" : "Update"} Webhook: ${ webhookNames[webhookObject.type] }`} sx={{ diff --git a/src/components/TableModals/WebhooksModal/utils.tsx b/src/components/TableModals/WebhooksModal/utils.tsx index 7c338babd..cefc6c2f7 100644 --- a/src/components/TableModals/WebhooksModal/utils.tsx +++ b/src/components/TableModals/WebhooksModal/utils.tsx @@ -26,17 +26,37 @@ 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; + auth:firebaseauth.BaseAuth; + storage:firebasestorage.Storage; + } + ) => 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; + auth:firebaseauth.BaseAuth; + storage:firebasestorage.Storage; + } + ) => Promise;`, ]; const additionalVariables = [ @@ -56,7 +76,7 @@ export const webhookNames: Record = { // twitter: "Twitter", stripe: "Stripe", basic: "Basic", - webform: "Web form", + webform: "Web Form", }; export interface IWebhookEditor { @@ -99,7 +119,7 @@ export function emptyWebhookObject( ): IWebhook { return { name: `${type} webhook`, - active: false, + active: true, endpoint: generateId(), type, parser: webhookSchemas[type].parser?.template(table), diff --git a/src/components/TableModals/WebhooksModal/webhooks.d.ts b/src/components/TableModals/WebhooksModal/webhooks.d.ts index 4a8715f8d..65df05302 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/TableSettingsDialog/form.tsx b/src/components/TableSettingsDialog/form.tsx index 0dddff69a..8cfcaa14f 100644 --- a/src/components/TableSettingsDialog/form.tsx +++ b/src/components/TableSettingsDialog/form.tsx @@ -1,6 +1,11 @@ import { find } from "lodash-es"; +import { useAtom } from "jotai"; import { Field, FieldType } from "@rowy/form-builder"; -import { TableSettingsDialogState } from "@src/atoms/projectScope"; +import { + projectIdAtom, + projectScope, + TableSettingsDialogState, +} from "@src/atoms/projectScope"; import { Link, ListItemText, Typography } from "@mui/material"; import OpenInNewIcon from "@src/components/InlineOpenInNewIcon"; @@ -9,6 +14,21 @@ import WarningIcon from "@mui/icons-material/WarningAmber"; import { WIKI_LINKS } from "@src/constants/externalLinks"; import { FieldType as TableFieldType } from "@src/constants/fields"; +function CollectionLink() { + const [projectId] = useAtom(projectIdAtom, projectScope); + + return ( + + Your collections + + + ); +} + export const tableSettings = ( mode: TableSettingsDialogState["mode"], roles: string[] | undefined, @@ -105,14 +125,7 @@ export const tableSettings = ( ) : ( "Choose which Firestore collection to display." )}{" "} - - Your collections - - + ), AddButtonProps: { @@ -311,6 +324,17 @@ export const tableSettings = ( label: "Suggested Firestore Rules", watchedField: "collection", }, + { + step: "accessControls", + type: FieldType.multiSelect, + name: "modifiableBy", + label: "Modifiable by", + labelPlural: "Modifier Roles", + options: roles ?? [], + defaultValue: ["ADMIN"], + required: true, + freeText: true, + }, // Step 4: Auditing { diff --git a/src/components/TableToolbar/Filters/Filters.tsx b/src/components/TableToolbar/Filters/Filters.tsx index b1c6b77e5..8ebc32ff0 100644 --- a/src/components/TableToolbar/Filters/Filters.tsx +++ b/src/components/TableToolbar/Filters/Filters.tsx @@ -112,7 +112,9 @@ export default function Filters() { setLocalFilters(filtersToApply); // Reset order so we don’t have to make a new index - setTableSorts([]); + if (filtersToApply.length) { + setTableSorts([]); + } }, [ hasTableFilters, hasUserFilters, diff --git a/src/components/TableToolbar/HiddenFields.tsx b/src/components/TableToolbar/HiddenFields.tsx index 0a5377132..ff1fc86a6 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 01b2aa4e3..4bdfc216e 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 40ec3f844..f164f90af 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,83 @@ 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, + transforms: [ + (value: any) => { + // if the value is an array, join it with a comma + for (let key in value) { + if (Array.isArray(value[key])) { + value[key] = value[key].join(","); + } + } + return value; + }, + ], + }; + + 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 +132,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 +162,7 @@ export default function ImportFromCsv() { {} ) ); + console.log(mappedRows); setImportCsv({ importType: importTypeRef.current, csvData: { columns, rows: mappedRows }, @@ -86,13 +178,17 @@ export default function ImportFromCsv() { async (acceptedFiles: File[]) => { try { const file = acceptedFiles[0]; + importTypeRef.current = + file.type === FileType.TSV + ? "tsv" + : file.type === FileType.JSON + ? "json" + : "csv"; const reader = new FileReader(); - reader.onload = (event: any) => parseCsv(event.target.result); + reader.onload = (event: any) => parseFile(event.target.result); reader.readAsText(file); - importTypeRef.current = - file.type === "text/tab-separated-values" ? "tsv" : "csv"; } 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 +203,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 +228,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 +238,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 +317,7 @@ export default function ImportFromCsv() { {isDragActive ? ( - Drop CSV or TSV file here… + Drop CSV or TSV or JSON file here… ) : ( <> @@ -227,8 +327,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 +349,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 +379,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/TableTutorial/Steps/Step2Add.tsx b/src/components/TableTutorial/Steps/Step2Add.tsx index 5e6d9dbe0..9c9024d56 100644 --- a/src/components/TableTutorial/Steps/Step2Add.tsx +++ b/src/components/TableTutorial/Steps/Step2Add.tsx @@ -44,7 +44,7 @@ function StepComponent({ setComplete }: ITableTutorialStepComponentProps) { const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); useEffect(() => { if ( - tableColumnsOrdered.some( + tableColumnsOrdered?.some( (c) => c.type === FieldType.rating && c.name.toLowerCase().includes("rating") ) diff --git a/src/components/Tables/TableGrid/TableCard.tsx b/src/components/Tables/TableGrid/TableCard.tsx index dd5489a9a..88472b5c4 100644 --- a/src/components/Tables/TableGrid/TableCard.tsx +++ b/src/components/Tables/TableGrid/TableCard.tsx @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { Card, @@ -25,6 +25,7 @@ export default function TableCard({ link, actions, }: ITableCardProps) { + const navigate = useNavigate(); return ( @@ -46,7 +47,11 @@ export default function TableCard({ backgroundColor: "action.input", borderRadius: 1, overflow: "hidden", + "&:hover": { + cursor: "pointer", + }, }} + onClick={() => navigate(link)} > + Link + + ) : ( + + ) + ) : undefined, } ); } catch (e) { diff --git a/src/components/fields/Action/Settings.tsx b/src/components/fields/Action/Settings.tsx index 1933818cc..735423efc 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 a43399782..b41ff94cd 100644 --- a/src/components/fields/Action/action.d.ts +++ b/src/components/fields/Action/action.d.ts @@ -15,12 +15,14 @@ type ActionContext = { auth: firebaseauth.BaseAuth; actionParams: actionParams; user: ActionUser; + logging: RowyLogging; }; type ActionResult = { success: boolean; message?: any; status?: string | number | null | undefined; + link?: string | { url: string; label: string }; }; type Action = (context: ActionContext) => Promise | ActionResult; diff --git a/src/components/fields/Action/index.tsx b/src/components/fields/Action/index.tsx index e2e8c1bda..7ab899e2d 100644 --- a/src/components/fields/Action/index.tsx +++ b/src/components/fields/Action/index.tsx @@ -30,6 +30,7 @@ export const config: IFieldConfig = { SideDrawerField, settings: Settings, requireConfiguration: true, + requireCloudFunction: true, sortKey: "status", }; export default config; diff --git a/src/components/fields/Action/templates.ts b/src/components/fields/Action/templates.ts index 5701ef244..46b0a0b58 100644 --- a/src/components/fields/Action/templates.ts +++ b/src/components/fields/Action/templates.ts @@ -1,54 +1,67 @@ -export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user}) => { - // Write your action code here - // for example: - // const authToken = await rowy.secrets.get("service") - // try { - // const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{ - // method: 'PUT', - // headers: { - // 'Content-Type': 'application/json', - // 'Authorization': authToken - // }, - // body: JSON.stringify(row) - // }) - // - // return { - // success: true, - // message: 'User updated successfully on example service', - // status: "upto date" - // } - // } catch (error) { - // return { - // success: false, - // message: 'User update failed on example service', - // } - // } - // checkout the documentation for more info: https://docs.rowy.io/field-types/action#script - }`; +export const RUN_ACTION_TEMPLATE = `const action:Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("action started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + // Example: + /* + const authToken = await rowy.secrets.get("service") + try { + const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{ + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': authToken + }, + body: JSON.stringify(row) + }) + return { + success: true, + message: 'User updated successfully on example service', + status: "upto date" + } + } catch (error) { + return { + success: false, + message: 'User update failed on example service', + } + } + */ + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`; -export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user}) => { - // Write your undo code here - // for example: - // const authToken = await rowy.secrets.get("service") - // try { - // const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{ - // method: 'DELETE', - // headers: { - // 'Content-Type': 'application/json', - // 'Authorization': authToken - // }, - // body: JSON.stringify(row) - // }) - // - // return { - // success: true, - // message: 'User deleted successfully on example service', - // status: null - // } - // } catch (error) { - // return { - // success: false, - // message: 'User delete failed on example service', - // } - // } - }`; +export const UNDO_ACTION_TEMPLATE = `const action : Action = async ({row,ref,db,storage,auth,actionParams,user,logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("action started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + // Example: + /* + const authToken = await rowy.secrets.get("service") + try { + const resp = await fetch('https://example.com/api/v1/users/'+ref.id,{ + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': authToken + }, + body: JSON.stringify(row) + }) + return { + success: true, + message: 'User deleted successfully on example service', + status: null + } + } catch (error) { + return { + success: false, + message: 'User delete failed on example service', + } + } + */ + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`; diff --git a/src/components/fields/Array/DisplayCell.tsx b/src/components/fields/Array/DisplayCell.tsx new file mode 100644 index 000000000..e934ea5f9 --- /dev/null +++ b/src/components/fields/Array/DisplayCell.tsx @@ -0,0 +1,24 @@ +import { useTheme } from "@mui/material"; +import { IDisplayCellProps } from "@src/components/fields/types"; + +export default function Array({ value }: IDisplayCellProps) { + const theme = useTheme(); + + if (!value) { + return null; + } + + return ( +
    + {JSON.stringify(value, null, 4)} +
    + ); +} diff --git a/src/components/fields/Array/SideDrawerField/AddButton.tsx b/src/components/fields/Array/SideDrawerField/AddButton.tsx new file mode 100644 index 000000000..4719970ea --- /dev/null +++ b/src/components/fields/Array/SideDrawerField/AddButton.tsx @@ -0,0 +1,92 @@ +import { useRef, useState } from "react"; +import { + Button, + ButtonGroup, + ListItemText, + MenuItem, + Select, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; + +import { ChevronDown as ArrowDropDownIcon } from "@src/assets/icons"; +import { FieldType } from "@src/components/fields/types"; +import { getFieldProp } from "@src/components/fields"; + +import { + ArraySupportedFields, + ArraySupportedFiledTypes, +} from "./SupportedTypes"; + +function AddButton({ handleAddNew }: { handleAddNew: Function }) { + const anchorEl = useRef(null); + const [open, setOpen] = useState(false); + const [fieldType, setFieldType] = useState( + FieldType.shortText + ); + + return ( + <> + + + + + + + + + ); +} + +export default AddButton; diff --git a/src/components/fields/Array/SideDrawerField/SupportedTypes.ts b/src/components/fields/Array/SideDrawerField/SupportedTypes.ts new file mode 100644 index 000000000..6f3fc20b4 --- /dev/null +++ b/src/components/fields/Array/SideDrawerField/SupportedTypes.ts @@ -0,0 +1,108 @@ +import { DocumentReference, GeoPoint, Timestamp } from "firebase/firestore"; + +import { FieldType } from "@src/components/fields/types"; + +import NumberValueSidebar from "@src/components/fields/Number/SideDrawerField"; +import ShortTextValueSidebar from "@src/components/fields/ShortText/SideDrawerField"; +import JsonValueSidebar from "@src/components/fields/Json/SideDrawerField"; +import CheckBoxValueSidebar from "@src/components/fields/Checkbox/SideDrawerField"; +import GeoPointValueSidebar from "@src/components/fields/GeoPoint/SideDrawerField"; +import DateTimeValueSidebar from "@src/components/fields/DateTime/SideDrawerField"; +import ReferenceValueSidebar from "@src/components/fields/Reference/SideDrawerField"; + +export const ArraySupportedFields = [ + FieldType.number, + FieldType.shortText, + FieldType.json, + FieldType.checkbox, + FieldType.geoPoint, + FieldType.dateTime, + FieldType.reference, +] as const; + +export type ArraySupportedFiledTypes = typeof ArraySupportedFields[number]; + +export const SupportedTypes = { + [FieldType.number]: { + Sidebar: NumberValueSidebar, + initialValue: 0, + dataType: "common", + instance: Object, + }, + [FieldType.shortText]: { + Sidebar: ShortTextValueSidebar, + initialValue: "", + dataType: "common", + instance: Object, + }, + [FieldType.checkbox]: { + Sidebar: CheckBoxValueSidebar, + initialValue: false, + dataType: "common", + instance: Object, + }, + [FieldType.json]: { + Sidebar: JsonValueSidebar, + initialValue: {}, + sx: [ + { + marginTop: "24px", + }, + ], + dataType: "common", + instance: Object, + }, + [FieldType.geoPoint]: { + Sidebar: GeoPointValueSidebar, + initialValue: new GeoPoint(0, 0), + dataType: "firestore-type", + instance: GeoPoint, + }, + [FieldType.dateTime]: { + Sidebar: DateTimeValueSidebar, + initialValue: Timestamp.now(), + dataType: "firestore-type", + instance: Timestamp, + }, + [FieldType.reference]: { + Sidebar: ReferenceValueSidebar, + initialValue: null, + dataType: "firestore-type", + instance: DocumentReference, + }, +}; + +export function detectType(value: any): ArraySupportedFiledTypes { + if (value === null) { + return FieldType.reference; + } + for (const supportedField of ArraySupportedFields) { + if (SupportedTypes[supportedField].dataType === "firestore-type") { + if (value instanceof SupportedTypes[supportedField].instance) { + return supportedField; + } + } + } + + switch (typeof value) { + case "bigint": + case "number": { + return FieldType.number; + } + case "string": { + return FieldType.shortText; + } + case "boolean": { + return FieldType.checkbox; + } + case "object": { + if (+new Date(value)) { + return FieldType.dateTime; + } + return FieldType.json; + } + default: { + return FieldType.shortText; + } + } +} diff --git a/src/components/fields/Array/SideDrawerField/index.tsx b/src/components/fields/Array/SideDrawerField/index.tsx new file mode 100644 index 000000000..b8415eb52 --- /dev/null +++ b/src/components/fields/Array/SideDrawerField/index.tsx @@ -0,0 +1,205 @@ +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from "react-beautiful-dnd"; + +import { Stack, Box, Button, ListItem, List } from "@mui/material"; +import ClearIcon from "@mui/icons-material/Clear"; +import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined"; +import DeleteIcon from "@mui/icons-material/DeleteOutline"; + +import { FieldType, ISideDrawerFieldProps } from "@src/components/fields/types"; +import { TableRowRef } from "@src/types/table"; + +import AddButton from "./AddButton"; +import { getPseudoColumn } from "./utils"; +import { + ArraySupportedFiledTypes, + detectType, + SupportedTypes, +} from "./SupportedTypes"; + +function ArrayFieldInput({ + onChange, + value, + _rowy_ref, + index, + onRemove, + onSubmit, + id, +}: { + index: number; + onRemove: (index: number) => void; + onChange: (value: any) => void; + value: any; + onSubmit: () => void; + _rowy_ref: TableRowRef; + id: string; +}) { + const typeDetected = detectType(value); + + const Sidebar = SupportedTypes[typeDetected].Sidebar; + return ( + + {(provided) => ( + + + + false ? theme.palette.action.disabledOpacity : 1, + }, + ]} + /> + + + + + onRemove(index)} + > + + + + )} + + ); +} + +export default function ArraySideDrawerField({ + column, + value, + onChange, + onSubmit, + disabled, + _rowy_ref, + onDirty, + ...props +}: ISideDrawerFieldProps) { + const handleAddNew = (fieldType: ArraySupportedFiledTypes) => { + onChange([...(value || []), SupportedTypes[fieldType].initialValue]); + onDirty(true); + }; + const handleChange = (newValue_: any, indexUpdated: number) => { + onChange( + [...(value || [])].map((v: any, i) => { + if (i === indexUpdated) { + return newValue_; + } + + return v; + }) + ); + }; + + const handleRemove = (index: number) => { + value.splice(index, 1); + onChange([...value]); + onDirty(true); + onSubmit(); + }; + + const handleClearField = () => { + onChange([]); + onSubmit(); + }; + + function handleOnDragEnd(result: DropResult) { + if ( + !result.destination || + result.destination.index === result.source.index + ) { + return; + } + const list = Array.from(value); + const [removed] = list.splice(result.source.index, 1); + list.splice(result.destination.index, 0, removed); + onChange(list); + onSubmit(); + } + + if (value === undefined || Array.isArray(value)) { + return ( + <> + + + {(provided) => ( + + {(value || []).map((v: any, index: number) => ( + handleChange(newValue, index)} + onRemove={handleRemove} + index={index} + onSubmit={onSubmit} + /> + ))} + {provided.placeholder} + + )} + + + + + ); + } + + return ( + + + {JSON.stringify(value, null, 4)} + + + + ); +} diff --git a/src/components/fields/Array/SideDrawerField/utils.ts b/src/components/fields/Array/SideDrawerField/utils.ts new file mode 100644 index 000000000..599c46949 --- /dev/null +++ b/src/components/fields/Array/SideDrawerField/utils.ts @@ -0,0 +1,59 @@ +import { ColumnConfig } from "@src/types/table"; +import { FieldType } from "@src/constants/fields"; +import { ArraySupportedFiledTypes } from "./SupportedTypes"; +import { GeoPoint, DocumentReference } from "firebase/firestore"; +export function getPseudoColumn( + fieldType: FieldType, + index: number, + value: any +): ColumnConfig { + return { + fieldName: (+new Date()).toString(), + index: index, + key: (+new Date()).toString(), + name: value + "", + type: fieldType, + }; +} + +// archive: detectType / TODO: remove +export function detectType(value: any): ArraySupportedFiledTypes { + if (value === null) { + return FieldType.reference; + } + console.log(typeof GeoPoint); + console.log(value instanceof DocumentReference, value); + + if (typeof value === "object") { + const keys = Object.keys(value); + // console.log({ keys, value }, typeof value); + if (keys.length === 2) { + if (keys.includes("_lat") && keys.includes("_long")) { + return FieldType.geoPoint; + } + if (keys.includes("nanoseconds") && keys.includes("seconds")) { + return FieldType.dateTime; + } + } + if (+new Date(value)) { + return FieldType.dateTime; + } + return FieldType.json; + } + + switch (typeof value) { + case "bigint": + case "number": { + return FieldType.number; + } + case "string": { + return FieldType.shortText; + } + case "boolean": { + return FieldType.checkbox; + } + default: { + return FieldType.shortText; + } + } +} diff --git a/src/components/fields/Array/index.tsx b/src/components/fields/Array/index.tsx new file mode 100644 index 000000000..d00722edd --- /dev/null +++ b/src/components/fields/Array/index.tsx @@ -0,0 +1,30 @@ +import { lazy } from "react"; +import DataArrayIcon from "@mui/icons-material/DataArray"; + +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; + +import DisplayCell from "./DisplayCell"; + +const SideDrawerField = lazy( + () => + import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Array" */) +); + +export const config: IFieldConfig = { + type: FieldType.array, + name: "Array", + group: "Code", + dataType: "object", + initialValue: [], + initializable: true, + icon: , + description: + "Connects to a sub-table in the current row. Also displays number of rows inside the sub-table. Max sub-table depth: 100.", + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", { + popoverProps: { PaperProps: { sx: { p: 1, minWidth: "200px" } } }, + }), + SideDrawerField, + requireConfiguration: false, +}; +export default config; diff --git a/src/components/fields/Connector/Select/PopupContents.tsx b/src/components/fields/Connector/Select/PopupContents.tsx index 941418c78..0bd770cbe 100644 --- a/src/components/fields/Connector/Select/PopupContents.tsx +++ b/src/components/fields/Connector/Select/PopupContents.tsx @@ -127,7 +127,9 @@ export default function PopupContents({ {hits.map((hit) => { - const isSelected = selectedValues.some((v) => v === hit[elementId]); + const isSelected = selectedValues?.some( + (v) => v === hit[elementId] + ); return ( (m: string, key: string) => { return get(data, objKey, defaultValue); }; -export const baseFunction = `const connectorFn: Connector = async ({query, row, user}) => { - // TODO: Implement your service function here +export const baseFunction = `const connectorFn: Connector = async ({query, row, user, logging}) => { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("connectorFn started") + + // Import any NPM package needed + // const lodash = require('lodash'); + return []; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY };`; export const getLabel = (config: any, row: TableRow) => { diff --git a/src/components/fields/Date/index.tsx b/src/components/fields/Date/index.tsx index 7078e1f18..a8989d48d 100644 --- a/src/components/fields/Date/index.tsx +++ b/src/components/fields/Date/index.tsx @@ -34,10 +34,9 @@ export const config: IFieldConfig = { SideDrawerField, filter: { operators: filterOperators, valueFormatter }, settings: Settings, - csvImportParser: (value, config) => - parse(value, config?.format ?? DATE_FORMAT, new Date()), + csvImportParser: (value, config) => parse(value, DATE_FORMAT, new Date()), csvExportFormatter: (value: any, config?: any) => - format(value.toDate(), config?.format ?? DATE_FORMAT), + format(value.toDate(), DATE_FORMAT), }; export default config; diff --git a/src/components/fields/DateTime/index.tsx b/src/components/fields/DateTime/index.tsx index d414fb6e2..38e0e4129 100644 --- a/src/components/fields/DateTime/index.tsx +++ b/src/components/fields/DateTime/index.tsx @@ -1,7 +1,7 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; -import { parseJSON, format } from "date-fns"; +import { format } from "date-fns"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; import DateTimeIcon from "@mui/icons-material/AccessTime"; @@ -46,9 +46,9 @@ export const config: IFieldConfig = { customInput: FilterCustomInput, }, settings: Settings, - csvImportParser: (value) => parseJSON(value).getTime(), + csvImportParser: (value) => new Date(value), csvExportFormatter: (value: any, config?: any) => - format(value.toDate(), config?.format ?? DATE_TIME_FORMAT), + format(value.toDate(), DATE_TIME_FORMAT), }; export default config; diff --git a/src/components/fields/Derivative/ContextMenuActions.tsx b/src/components/fields/Derivative/ContextMenuActions.tsx index 015e3f7aa..92d781d84 100644 --- a/src/components/fields/Derivative/ContextMenuActions.tsx +++ b/src/components/fields/Derivative/ContextMenuActions.tsx @@ -2,13 +2,17 @@ import { useAtom } from "jotai"; import { find, get } from "lodash-es"; import { useSnackbar } from "notistack"; +import { Button } from "@mui/material"; import ReEvalIcon from "@mui/icons-material/ReplayOutlined"; import EvalIcon from "@mui/icons-material/PlayCircleOutline"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; import { projectScope, compatibleRowyRunVersionAtom, rowyRunAtom, + projectIdAtom, + projectSettingsAtom, } from "@src/atoms/projectScope"; import { tableScope, @@ -34,6 +38,8 @@ export const ContextMenuActions: IFieldConfig["contextMenuActions"] = ( const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const [tableSchema] = useAtom(tableSchemaAtom, tableScope); const [tableRows] = useAtom(tableRowsAtom, tableScope); + const [projectId] = useAtom(projectIdAtom, projectScope); + const [projectSettings] = useAtom(projectSettingsAtom, projectScope); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const [compatibleRowyRunVersion] = useAtom( compatibleRowyRunVersionAtom, @@ -76,8 +82,32 @@ export const ContextMenuActions: IFieldConfig["contextMenuActions"] = ( } else { enqueueSnackbar("Cell evaluated", { variant: "success" }); } - } catch (error) { - enqueueSnackbar(`Failed: ${error}`, { variant: "error" }); + } catch (error: any) { + if (error.message === "Failed to fetch") { + enqueueSnackbar( + "Evaluation failed. Rowy Run is likely out of memory. Please allocate more in GCP console.", + { + variant: "warning", + persist: true, + action: (snackbarId) => ( + + ), + } + ); + } else { + enqueueSnackbar(`Failed: ${error}`, { variant: "error" }); + } } }; const isEmpty = diff --git a/src/components/fields/Derivative/Settings.tsx b/src/components/fields/Derivative/Settings.tsx index 32ce65dc0..1c2486d29 100644 --- a/src/components/fields/Derivative/Settings.tsx +++ b/src/components/fields/Derivative/Settings.tsx @@ -65,16 +65,28 @@ export default function Settings({ : config.derivativeFn ? config.derivativeFn : config?.script - ? `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{ - ${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")} - }` - : `const derivative:Derivative = async ({row,ref,db,storage,auth})=>{ - // Write your derivative code here - // for example: - // const sum = row.a + row.b; - // return sum; - // checkout the documentation for more info: https://docs.rowy.io/field-types/derivative - }`; + ? `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{ + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("derivative started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + ${config.script.replace(/utilFns.getSecret/g, "rowy.secrets.get")} + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}` + : `const derivative:Derivative = async ({row,ref,db,storage,auth,logging})=>{ + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + logging.log("derivative started") + + // Import any NPM package needed + // const lodash = require('lodash'); + + // Example: + // const sum = row.a + row.b; + // return sum; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +}`; return ( <> diff --git a/src/components/fields/Derivative/derivative.d.ts b/src/components/fields/Derivative/derivative.d.ts index 3af58e11e..a56afeba1 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/components/fields/Derivative/index.tsx b/src/components/fields/Derivative/index.tsx index d046b4d31..4c18a0f31 100644 --- a/src/components/fields/Derivative/index.tsx +++ b/src/components/fields/Derivative/index.tsx @@ -21,5 +21,6 @@ export const config: IFieldConfig = { settings: Settings, settingsValidator, requireConfiguration: true, + requireCloudFunction: true, }; export default config; diff --git a/src/components/fields/Email/Settings.tsx b/src/components/fields/Email/Settings.tsx index 722467369..f3929b71f 100644 --- a/src/components/fields/Email/Settings.tsx +++ b/src/components/fields/Email/Settings.tsx @@ -2,27 +2,31 @@ import { ISettingsProps } from "@src/components/fields/types"; import { TextField, Button } from "@mui/material"; export default function Settings({ onChange, config }: ISettingsProps) { - - const copyStandardRegex = () => { - onChange("validationRegex")("^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+.[a-zA-z]{2,3}$"); - } - - return ( - <> - { - if (e.target.value === "") onChange("validationRegex")(null); - else onChange("validationRegex")(e.target.value); - }} - /> - - + const copyStandardRegex = () => { + onChange("validationRegex")( + "^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+.[a-zA-z]{2,3}$" ); + }; + + return ( + <> + { + if (e.target.value === "") onChange("validationRegex")(null); + else onChange("validationRegex")(e.target.value); + }} + /> + + + ); } diff --git a/src/components/fields/File/EditorCell.tsx b/src/components/fields/File/EditorCell.tsx index 247c99faa..dedb97f64 100644 --- a/src/components/fields/File/EditorCell.tsx +++ b/src/components/fields/File/EditorCell.tsx @@ -1,4 +1,3 @@ -import { useCallback } from "react"; import { IEditorCellProps } from "@src/components/fields/types"; import { useSetAtom } from "jotai"; @@ -15,6 +14,15 @@ import { DATE_TIME_FORMAT } from "@src/constants/dates"; import { FileValue } from "@src/types/table"; import useFileUpload from "./useFileUpload"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, + ResponderProvided, +} from "react-beautiful-dnd"; + export default function File_({ column, value, @@ -25,11 +33,40 @@ export default function File_({ }: IEditorCellProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); - const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload(_rowy_ref, column.key, { multiple: true }); + const { + loading, + progress, + handleDelete, + localFiles, + dropzoneState, + handleUpdate, + } = useFileUpload(_rowy_ref, column.key, { multiple: true }); const { isDragActive, getRootProps, getInputProps } = dropzoneState; const dropzoneProps = getRootProps(); + + const onDragEnd = (result: DropResult, provided: ResponderProvided) => { + const { destination, source } = result; + + if (!destination) { + return; + } + + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) { + return; + } + + const newValue = Array.from(value); + + newValue.splice(source.index, 1); + newValue.splice(destination.index, 0, value[source.index]); + + handleUpdate([...newValue]); + }; + return ( - - {Array.isArray(value) && - value.map((file: FileValue) => ( - 1 ? { maxWidth: `calc(100% - 12px)` } : {} - } - > - + + {(provided) => ( + + - } - sx={{ - "& .MuiChip-label": { - lineHeight: 5 / 3, - }, - }} - onClick={(e: any) => e.stopPropagation()} - component="a" - href={file.downloadURL} - target="_blank" - rel="noopener noreferrer" - clickable - onDelete={ - disabled - ? undefined - : () => - confirm({ - handleConfirm: () => handleDelete(file), - title: "Delete file?", - body: "This file cannot be recovered after", - confirm: "Delete", - confirmColor: "error", - }) - } - tabIndex={tabIndex} - style={{ width: "100%", cursor: "pointer" }} - /> - - - ))} - {localFiles && - localFiles.map((file) => ( - - } - label={file.name} - deleteIcon={ - - } - /> - - ))} - + {Array.isArray(value) && + value.map((file: FileValue, i) => ( + + {(provided) => ( + 1 ? "calc(100% - 12px)" : "initial" + }`, + ...provided.draggableProps.style, + }} + > + {value.length > 1 && ( +
    + +
    + )} + + } + sx={{ + "& .MuiChip-label": { + lineHeight: 5 / 3, + }, + }} + onClick={(e: any) => e.stopPropagation()} + component="a" + href={file.downloadURL} + target="_blank" + rel="noopener noreferrer" + clickable + onDelete={ + disabled + ? undefined + : (e) => { + e.preventDefault(); + confirm({ + handleConfirm: () => handleDelete(file), + title: "Delete file?", + body: "This file cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + }); + } + } + tabIndex={tabIndex} + style={{ width: "100%", cursor: "pointer" }} + /> + +
    + )} +
    + ))} +
    + + {localFiles && + localFiles.map((file) => ( + + } + label={file.name} + deleteIcon={ + + } + /> + + ))} + + )} + + {!loading ? ( !disabled && ( diff --git a/src/components/fields/File/SideDrawerField.tsx b/src/components/fields/File/SideDrawerField.tsx index 00287c23d..5d9ca52cd 100644 --- a/src/components/fields/File/SideDrawerField.tsx +++ b/src/components/fields/File/SideDrawerField.tsx @@ -20,6 +20,15 @@ import { FileValue } from "@src/types/table"; import useFileUpload from "./useFileUpload"; import { FileIcon } from "."; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, + ResponderProvided, +} from "react-beautiful-dnd"; + export default function File_({ column, _rowy_ref, @@ -72,52 +81,94 @@ export default function File_({ )} - - {Array.isArray(value) && - value.map((file: FileValue) => ( - - -
    - } - label={file.name} - onClick={() => window.open(file.downloadURL)} - onDelete={ - !disabled - ? () => - confirm({ - title: "Delete file?", - body: "This file cannot be recovered after", - confirm: "Delete", - confirmColor: "error", - handleConfirm: () => handleDelete(file), - }) - : undefined - } - /> -
    -
    -
    - ))} + console.log("onDragEnd")}> + + {(provided) => ( + + {Array.isArray(value) && + value.map((file: FileValue, i) => ( + + {(provided) => ( + + {value.length > 1 && ( +
    + +
    + )} + +
    + } + label={file.name} + onClick={() => window.open(file.downloadURL)} + onDelete={ + !disabled + ? () => + confirm({ + title: "Delete file?", + body: "This file cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: () => handleDelete(file), + }) + : undefined + } + /> +
    +
    +
    + )} +
    + ))} - {localFiles && - localFiles.map((file) => ( - - } - label={file.name} - deleteIcon={ - - } - /> + {localFiles && + localFiles.map((file) => ( + + } + label={file.name} + deleteIcon={ + + } + /> + + ))} + {provided.placeholder} - ))} -
    + )} +
    +
    ); } diff --git a/src/components/fields/File/useFileUpload.ts b/src/components/fields/File/useFileUpload.ts index d99ccf674..22795349e 100644 --- a/src/components/fields/File/useFileUpload.ts +++ b/src/components/fields/File/useFileUpload.ts @@ -75,6 +75,15 @@ export default function useFileUpload( [deleteUpload, docRef, fieldName, updateField] ); + // Drag and Drop + const handleUpdate = (files: any) => { + updateField({ + path: docRef.path, + fieldName, + value: files, + }); + }; + return { localFiles, progress, @@ -83,5 +92,6 @@ export default function useFileUpload( handleUpload, handleDelete, dropzoneState, + handleUpdate, }; } diff --git a/src/components/fields/Formula/DisplayCell.tsx b/src/components/fields/Formula/DisplayCell.tsx new file mode 100644 index 000000000..adf59b9df --- /dev/null +++ b/src/components/fields/Formula/DisplayCell.tsx @@ -0,0 +1,27 @@ +import CircularProgressOptical from "@src/components/CircularProgressOptical"; +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { useFormula } from "./useFormula"; +import { defaultFn, getDisplayCell } from "./util"; + +export default function Formula(props: IDisplayCellProps) { + const { result, error, loading } = useFormula({ + row: props.row, + ref: props._rowy_ref, + listenerFields: props.column.config?.listenerFields || [], + formulaFn: props.column.config?.formulaFn || defaultFn, + }); + + const type = props.column.config?.renderFieldType; + const DisplayCell = getDisplayCell(type); + + if (error) { + return <>Error: {error.message}; + } + + if (loading) { + return ; + } + + return ; +} diff --git a/src/components/fields/Formula/PreviewTable.tsx b/src/components/fields/Formula/PreviewTable.tsx new file mode 100644 index 000000000..6cf85c229 --- /dev/null +++ b/src/components/fields/Formula/PreviewTable.tsx @@ -0,0 +1,75 @@ +import { Provider, useAtom } from "jotai"; + +import { currentUserAtom } from "@src/atoms/projectScope"; +import { + tableRowsDbAtom, + tableScope, + tableSettingsAtom, +} from "@src/atoms/tableScope"; + +import TablePage from "@src/pages/Table/TablePage"; +import { TableSchema } from "@src/types/table"; +import { Box, InputLabel } from "@mui/material"; +import TableSourcePreview from "./TableSourcePreview"; + +const PreviewTable = ({ tableSchema }: { tableSchema: TableSchema }) => { + const [currentUser] = useAtom(currentUserAtom, tableScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + return ( + + Preview table + + + div:first-child": { + display: "none", + }, + // table grid + "& > div:nth-of-type(2)": { + height: "unset", + }, + // emtpy state + "& .empty-state": { + display: "none", + }, + // column actions - add column + '& [data-col-id="_rowy_column_actions"]': { + display: "none", + }, + // row headers - sort by, column settings + '& [data-row-id="_rowy_header"] > button': { + display: "none", + }, + // row headers - drag handler + '& [data-row-id="_rowy_header"] > .column-drag-handle': { + display: "none !important", + }, + // row headers - resize handler + '& [data-row-id="_rowy_header"] >:last-child': { + display: "none !important", + }, + }} + > + + + + + ); +}; + +export default PreviewTable; diff --git a/src/components/fields/Formula/Settings.tsx b/src/components/fields/Formula/Settings.tsx new file mode 100644 index 000000000..666615fe7 --- /dev/null +++ b/src/components/fields/Formula/Settings.tsx @@ -0,0 +1,169 @@ +import { lazy, Suspense, useMemo } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { useAtom } from "jotai"; +import MultiSelect from "@rowy/multiselect"; + +import { Grid, InputLabel, Stack, FormHelperText } from "@mui/material"; + +import { + tableColumnsOrderedAtom, + tableSchemaAtom, + tableScope, +} from "@src/atoms/tableScope"; + +import FieldSkeleton from "@src/components/SideDrawer/FieldSkeleton"; +import { ISettingsProps } from "@src/components/fields/types"; +import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; +import { DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT } from "@src/components/Table"; +import { ColumnConfig } from "@src/types/table"; + +import { defaultFn, listenerFieldTypes, outputFieldTypes } from "./util"; +import PreviewTable from "./PreviewTable"; +import { getFieldProp } from ".."; + +/* eslint-disable import/no-webpack-loader-syntax */ +import formulaDefs from "!!raw-loader!./formula.d.ts"; +import { WIKI_LINKS } from "@src/constants/externalLinks"; +import CodeEditorHelper from "@src/components/CodeEditor/CodeEditorHelper"; + +const CodeEditor = lazy( + () => + import("@src/components/CodeEditor" /* webpackChunkName: "CodeEditor" */) +); + +const diagnosticsOptions = { + noSemanticValidation: false, + noSyntaxValidation: false, + noSuggestionDiagnostics: true, +}; + +export default function Settings({ + config, + fieldName, + onChange, + onBlur, + errors, +}: ISettingsProps) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + const returnType = getFieldProp("dataType", config.renderFieldType) ?? "any"; + const formulaFn = config?.formulaFn ? config.formulaFn : defaultFn; + + const previewTableSchema = useMemo(() => { + const columns = tableSchema.columns || {}; + return { + ...tableSchema, + columns: Object.keys(columns).reduce((previewSchema, key) => { + if ((config.listenerFields || []).includes(columns[key].fieldName)) { + previewSchema[key] = { + ...columns[key], + fixed: false, + width: DEFAULT_COL_WIDTH, + }; + } + if (columns[key].fieldName === fieldName) { + previewSchema[key] = { + ...columns[key], + config, + fixed: true, + }; + } + return previewSchema; + }, {} as { [key: string]: ColumnConfig }), + rowHeight: DEFAULT_ROW_HEIGHT, + }; + }, [config, fieldName, tableSchema]); + + return ( + + + + listenerFieldTypes.includes(c.type)) + .map((c) => ({ label: c.name, value: c.key }))} + value={config.listenerFields ?? []} + onChange={onChange("listenerFields")} + TextFieldProps={{ + helperText: ( + <> + {errors.listenerFields && ( + + {errors.listenerFields} + + )} + + Changes to these fields will trigger the evaluation of the + column. + + + ), + FormHelperTextProps: { component: "div" } as any, + required: true, + error: errors.listenerFields, + onBlur, + }} + /> + + + + { + onChange("renderFieldType")(value); + }} + TextFieldProps={{ + required: true, + error: errors.renderFieldType, + helperText: errors.renderFieldType, + onBlur, + }} + /> + + + + Formula script +
    + + }> + ` + ), + ]} + onChange={useDebouncedCallback(onChange("formulaFn"), 300)} + /> + +
    + +
    + ); +} + +export const settingsValidator = (config: any) => { + const errors: Record = {}; + if (config.error) errors.error = config.error; + return errors; +}; diff --git a/src/components/fields/Formula/TableSourcePreview.ts b/src/components/fields/Formula/TableSourcePreview.ts new file mode 100644 index 000000000..07712f87f --- /dev/null +++ b/src/components/fields/Formula/TableSourcePreview.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { useAtomCallback } from "jotai/utils"; +import { cloneDeep, findIndex, sortBy } from "lodash-es"; + +import { + _deleteRowDbAtom, + _updateRowDbAtom, + tableNextPageAtom, + tableRowsDbAtom, + tableSchemaAtom, + tableScope, + tableSettingsAtom, +} from "@src/atoms/tableScope"; + +import { TableRow, TableSchema } from "@src/types/table"; +import { updateRowData } from "@src/utils/table"; +import { serializeRef } from "./util"; + +const TableSourcePreview = ({ tableSchema }: { tableSchema: TableSchema }) => { + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const setTableSchemaAtom = useSetAtom(tableSchemaAtom, tableScope); + const setRows = useSetAtom(tableRowsDbAtom, tableScope); + useEffect(() => { + setRows( + ["preview-doc-1", "preview-doc-2", "preview-doc-3"].map((docId) => ({ + _rowy_ref: serializeRef(`${tableSettings.collection}/${docId}`), + })) + ); + }, [setRows, tableSettings.collection]); + + useEffect(() => { + setTableSchemaAtom(() => ({ + ...tableSchema, + _rowy_ref: "preview", + })); + }, [tableSchema, setTableSchemaAtom]); + + const readRowsDb = useAtomCallback( + useCallback((get) => get(tableRowsDbAtom) || [], []), + tableScope + ); + + const setUpdateRowDb = useSetAtom(_updateRowDbAtom, tableScope); + setUpdateRowDb(() => async (path: string, update: Partial) => { + const rows = await readRowsDb(); + const index = findIndex(rows, ["_rowy_ref.path", path]); + if (index === -1) { + setRows( + sortBy( + [ + ...rows, + { ...update, _rowy_ref: { id: path.split("/").pop()!, path } }, + ], + ["_rowy_ref.id"] + ) + ); + } else { + const updatedRows = [...rows]; + updatedRows[index] = cloneDeep(rows[index]); + updatedRows[index] = updateRowData(updatedRows[index], update); + setRows(updatedRows); + } + return Promise.resolve(); + }); + + const setDeleteRowDb = useSetAtom(_deleteRowDbAtom, tableScope); + setDeleteRowDb(() => async (path: string) => { + const rows = await readRowsDb(); + const index = findIndex(rows, ["_rowy_ref.path", path]); + if (index > -1) { + setRows(rows.filter((_, idx) => idx !== index)); + } + return Promise.resolve(); + }); + + const setNextPageAtom = useSetAtom(tableNextPageAtom, tableScope); + setNextPageAtom({ loading: false, available: false }); + + return null; +}; + +export default TableSourcePreview; diff --git a/src/components/fields/Formula/formula.d.ts b/src/components/fields/Formula/formula.d.ts new file mode 100644 index 000000000..8c6210c80 --- /dev/null +++ b/src/components/fields/Formula/formula.d.ts @@ -0,0 +1,9 @@ +type RowRef = { id: string; path: string; parent: T }; +interface Ref extends RowRef {} + +type FormulaContext = { + row: Row; + ref: Ref; +}; + +type Formula = (context: FormulaContext) => "PLACEHOLDER_OUTPUT_TYPE"; diff --git a/src/components/fields/Formula/index.tsx b/src/components/fields/Formula/index.tsx new file mode 100644 index 000000000..2394092bd --- /dev/null +++ b/src/components/fields/Formula/index.tsx @@ -0,0 +1,24 @@ +import FormulaIcon from "@mui/icons-material/Functions"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; +import DisplayCell from "./DisplayCell"; + +import Settings, { settingsValidator } from "./Settings"; + +export const config: IFieldConfig = { + type: FieldType.formula, + name: "Formula", + group: "Client Function", + dataType: "any", + initialValue: "", + icon: , + description: "Client Function (Alpha)", + TableCell: withRenderTableCell(DisplayCell as any, null, undefined, { + usesRowData: true, + }), + SideDrawerField: () => null as any, + settings: Settings, + settingsValidator: settingsValidator, + requireConfiguration: true, +}; +export default config; diff --git a/src/components/fields/Formula/useFormula.tsx b/src/components/fields/Formula/useFormula.tsx new file mode 100644 index 000000000..f6c44b758 --- /dev/null +++ b/src/components/fields/Formula/useFormula.tsx @@ -0,0 +1,82 @@ +import { useEffect, useMemo, useState } from "react"; +import { pick, zipObject } from "lodash-es"; +import { useAtom } from "jotai"; + +import { TableRow, TableRowRef } from "@src/types/table"; +import { tableColumnsOrderedAtom, tableScope } from "@src/atoms/tableScope"; + +import { + listenerFieldTypes, + serializeRef, + useDeepCompareMemoize, +} from "./util"; + +export const useFormula = ({ + row, + ref, + listenerFields, + formulaFn, +}: { + row: TableRow; + ref: TableRowRef; + listenerFields: string[]; + formulaFn: string; +}) => { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + + const availableColumns = tableColumnsOrdered + .filter((c) => listenerFieldTypes.includes(c.type)) + .map((c) => c.key); + + const availableFields = useMemo( + () => ({ + ...zipObject( + availableColumns, + Array(availableColumns.length).fill(undefined) + ), + ...pick(row, availableColumns), + }), + [row, availableColumns] + ); + + const listeners = useMemo( + () => pick(availableFields, listenerFields), + [availableFields, listenerFields] + ); + + useEffect(() => { + setError(null); + setLoading(true); + + const worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + }); + worker.onmessage = ({ data: { result, error } }: any) => { + worker.terminate(); + if (error) { + setError(error); + } else { + setResult(result); + } + setLoading(false); + }; + + worker.postMessage( + JSON.stringify({ + formulaFn, + row: availableFields, + ref: serializeRef(ref.path), + }) + ); + + return () => { + worker.terminate(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [useDeepCompareMemoize(listeners), formulaFn]); + + return { result, error, loading }; +}; diff --git a/src/components/fields/Formula/util.tsx b/src/components/fields/Formula/util.tsx new file mode 100644 index 000000000..71973a2ec --- /dev/null +++ b/src/components/fields/Formula/util.tsx @@ -0,0 +1,144 @@ +import { useMemo, useRef } from "react"; +import { isEqual } from "lodash-es"; + +import { FieldType } from "@src/constants/fields"; + +import ShortTextDisplayCell from "@src/components/fields/ShortText/DisplayCell"; +import LongTextDisplayCell from "@src/components/fields/LongText/DisplayCell"; +import RichTextDisplayCell from "@src/components/fields/RichText/DisplayCell"; +import UrlDisplayCell from "@src/components/fields/Url/DisplayCell"; +import NumberDisplayCell from "@src/components/fields/Number/DisplayCell"; +import CheckboxDisplayCell from "@src/components/fields/Checkbox/DisplayCell"; +import PercentageDisplayCell from "@src/components/fields/Percentage/DisplayCell"; +import RatingDisplayCell from "@src/components/fields/Rating/DisplayCell"; +import SliderDisplayCell from "@src/components/fields/Slider/DisplayCell"; +import SingleSelectDisplayCell from "@src/components/fields/SingleSelect/DisplayCell"; +import MultiSelectDisplayCell from "@src/components/fields/MultiSelect/DisplayCell"; +import ColorDisplayCell from "@src/components/fields/Color/DisplayCell"; +import GeoPointDisplayCell from "@src/components/fields/GeoPoint/DisplayCell"; +import DateDisplayCell from "@src/components/fields/Date/DisplayCell"; +import DateTimeDisplayCell from "@src/components/fields/DateTime/DisplayCell"; +import ImageDisplayCell from "@src/components/fields/Image/DisplayCell"; +import FileDisplayCell from "@src/components/fields/File/DisplayCell"; +import JsonDisplayCell from "@src/components/fields/Json/DisplayCell"; +import CodeDisplayCell from "@src/components/fields/Code/DisplayCell"; +import MarkdownDisplayCell from "@src/components/fields/Markdown/DisplayCell"; +import CreatedByDisplayCell from "@src/components/fields/CreatedBy/DisplayCell"; +import { TableRowRef } from "@src/types/table"; +import { DocumentData, DocumentReference } from "firebase/firestore"; + +export function useDeepCompareMemoize(value: T) { + const ref = useRef(value); + const signalRef = useRef(0); + + if (!isEqual(value, ref.current)) { + ref.current = value; + signalRef.current += 1; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => ref.current, [signalRef.current]); +} + +export const listenerFieldTypes = Object.values(FieldType).filter( + (type) => + ![FieldType.formula, FieldType.subTable, FieldType.last].includes(type) +); + +export const outputFieldTypes = Object.values(FieldType).filter( + (type) => + ![ + FieldType.formula, + FieldType.derivative, + FieldType.action, + FieldType.status, + FieldType.aggregate, + FieldType.connectService, + FieldType.connectTable, + FieldType.connector, + FieldType.duration, + FieldType.subTable, + FieldType.reference, + FieldType.createdAt, + FieldType.createdBy, + FieldType.updatedAt, + FieldType.updatedBy, + FieldType.last, + ].includes(type) +); + +export const defaultFn = `const formula:Formula = async ({ row, ref })=> { + // WRITE YOUR CODE ONLY BELOW THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY + + // Example: + // return row.a + row.b; + // WRITE YOUR CODE ONLY ABOVE THIS LINE. DO NOT WRITE CODE/COMMENTS OUTSIDE THE FUNCTION BODY +} +`; + +export const getDisplayCell = (type: FieldType) => { + switch (type) { + case FieldType.longText: + return LongTextDisplayCell; + case FieldType.richText: + return RichTextDisplayCell; + case FieldType.url: + return UrlDisplayCell; + case FieldType.number: + return NumberDisplayCell; + case FieldType.checkbox: + return CheckboxDisplayCell; + case FieldType.percentage: + return PercentageDisplayCell; + case FieldType.rating: + return RatingDisplayCell; + case FieldType.slider: + return SliderDisplayCell; + case FieldType.singleSelect: + return SingleSelectDisplayCell; + case FieldType.multiSelect: + return MultiSelectDisplayCell; + case FieldType.color: + return ColorDisplayCell; + case FieldType.geoPoint: + return GeoPointDisplayCell; + case FieldType.date: + return DateDisplayCell; + case FieldType.dateTime: + return DateTimeDisplayCell; + case FieldType.image: + return ImageDisplayCell; + case FieldType.file: + return FileDisplayCell; + case FieldType.json: + return JsonDisplayCell; + case FieldType.code: + return CodeDisplayCell; + case FieldType.markdown: + return MarkdownDisplayCell; + case FieldType.createdBy: + return CreatedByDisplayCell; + default: + return ShortTextDisplayCell; + } +}; + +export const serializeRef = (path: string, maxDepth = 20) => { + const pathArr = path.split("/"); + const serializedRef = { + path: pathArr.join("/"), + id: pathArr.pop(), + } as any; + let curr: TableRowRef | Partial> = + serializedRef; + let depth = 0; + while (pathArr.length > 0 && curr && depth < maxDepth) { + (curr.parent as any) = { + path: pathArr.join("/"), + id: pathArr.pop(), + } as Partial>; + curr = curr.parent as any; + maxDepth++; + } + return serializedRef; +}; diff --git a/src/components/fields/Formula/worker.ts b/src/components/fields/Formula/worker.ts new file mode 100644 index 000000000..2b0af366d --- /dev/null +++ b/src/components/fields/Formula/worker.ts @@ -0,0 +1,25 @@ +onmessage = async ({ data }) => { + try { + const { formulaFn, row, ref } = JSON.parse(data); + const AsyncFunction = async function () {}.constructor as any; + const [_, fnBody] = formulaFn.match(/=>\s*({?[\s\S]*}?)$/); + if (!fnBody) return; + const fn = new AsyncFunction( + "row", + "ref", + `const fn = async () => \n${fnBody}\n return fn();` + ); + const result = await fn(row, ref); + postMessage({ result }); + } catch (error: any) { + console.error("Error: ", error); + postMessage({ + error, + }); + } finally { + // eslint-disable-next-line no-restricted-globals + self.close(); + } +}; + +export {}; diff --git a/src/components/fields/Image/EditorCell.tsx b/src/components/fields/Image/EditorCell.tsx index ceec43070..b3a08976c 100644 --- a/src/components/fields/Image/EditorCell.tsx +++ b/src/components/fields/Image/EditorCell.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { IEditorCellProps } from "@src/components/fields/types"; -import { useAtom, useSetAtom } from "jotai"; +import { useSetAtom } from "jotai"; import { assignIn } from "lodash-es"; import { alpha, Box, Stack, Grid, IconButton, ButtonBase } from "@mui/material"; @@ -11,13 +11,20 @@ import Thumbnail from "@src/components/Thumbnail"; import CircularProgressOptical from "@src/components/CircularProgressOptical"; import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope"; -import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope"; -import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; import { FileValue } from "@src/types/table"; import useFileUpload from "@src/components/fields/File/useFileUpload"; import { IMAGE_MIME_TYPES } from "./index"; import { imgSx, thumbnailSx, deleteImgHoverSx } from "./DisplayCell"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, + ResponderProvided, +} from "react-beautiful-dnd"; + export default function Image_({ column, value, @@ -28,11 +35,17 @@ export default function Image_({ }: IEditorCellProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); - const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload(_rowy_ref, column.key, { - multiple: true, - accept: IMAGE_MIME_TYPES, - }); + const { + loading, + progress, + handleDelete, + localFiles, + dropzoneState, + handleUpdate, + } = useFileUpload(_rowy_ref, column.key, { + multiple: true, + accept: IMAGE_MIME_TYPES, + }); const localImages = useMemo( () => @@ -45,6 +58,28 @@ export default function Image_({ const { getRootProps, getInputProps, isDragActive } = dropzoneState; const dropzoneProps = getRootProps(); + const onDragEnd = (result: DropResult, provided: ResponderProvided) => { + const { destination, source } = result; + + if (!destination) { + return; + } + + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) { + return; + } + + const newValue = Array.from(value); + + newValue.splice(source.index, 1); + newValue.splice(destination.index, 0, value[source.index]); + + handleUpdate([...newValue]); + }; + let thumbnailSize = "100x100"; if (rowHeight > 50) thumbnailSize = "200x200"; if (rowHeight > 100) thumbnailSize = "400x400"; @@ -84,62 +119,102 @@ export default function Image_({ marginLeft: "0 !important", }} > - - {Array.isArray(value) && - value.map((file: FileValue, i) => ( - - { - confirm({ - title: "Delete image?", - body: "This image cannot be recovered after", - confirm: "Delete", - confirmColor: "error", - handleConfirm: () => handleDelete(file), - }); - }} - disabled={disabled} - tabIndex={tabIndex} - > - - - - - - - ))} - - {localImages && - localImages.map((image) => ( - - - `0 0 0 1px ${theme.palette.divider} inset`, - }, - ]} - style={{ - backgroundImage: `url("${image.localURL}")`, - }} - /> + + + {(provided) => ( + + {Array.isArray(value) && + value.map((file: FileValue, i) => ( + + {(provided) => ( + + {value.length > 1 && ( +
    + +
    + )} + { + confirm({ + title: "Delete image?", + body: "This image cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: () => handleDelete(file), + }); + }} + disabled={disabled} + tabIndex={tabIndex} + > + + + + + +
    + )} +
    + ))} + {localImages && + localImages.map((image) => ( + + + `0 0 0 1px ${theme.palette.divider} inset`, + }, + ]} + style={{ + backgroundImage: `url("${image.localURL}")`, + }} + /> + + ))} + {provided.placeholder}
    - ))} -
    + )} + +
  • {!loading ? ( diff --git a/src/components/fields/Image/SideDrawerField.tsx b/src/components/fields/Image/SideDrawerField.tsx index 70c58b885..9efb2dfda 100644 --- a/src/components/fields/Image/SideDrawerField.tsx +++ b/src/components/fields/Image/SideDrawerField.tsx @@ -26,6 +26,15 @@ import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils"; import useFileUpload from "@src/components/fields/File/useFileUpload"; import { IMAGE_MIME_TYPES } from "."; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, + ResponderProvided, +} from "react-beautiful-dnd"; + const imgSx = { position: "relative", width: 80, @@ -94,6 +103,7 @@ export default function Image_({ uploaderState, localFiles, dropzoneState, + handleUpdate, } = useFileUpload(_rowy_ref, column.key, { multiple: true, accept: IMAGE_MIME_TYPES, @@ -109,6 +119,28 @@ export default function Image_({ const { getRootProps, getInputProps, isDragActive } = dropzoneState; + const onDragEnd = (result: DropResult, provided: ResponderProvided) => { + const { destination, source } = result; + + if (!destination) { + return; + } + + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) { + return; + } + + const newValue = Array.from(value); + + newValue.splice(source.index, 1); + newValue.splice(destination.index, 0, value[source.index]); + + handleUpdate([...newValue]); + }; + return ( <> {!disabled && ( @@ -151,112 +183,158 @@ export default function Image_({ )} - - {Array.isArray(value) && - value.map((image: FileValue) => ( - - {disabled ? ( - - window.open(image.downloadURL, "_blank")} - className="img" + + + {(provided) => ( + + {Array.isArray(value) && + value.map((image: FileValue, i) => ( + - - - {disabled ? : } - - - - ) : ( -
    - - - ( + + {disabled ? ( + + + window.open(image.downloadURL, "_blank") + } + className="img" + > + + + {disabled ? ( + + ) : ( + + )} + + + + ) : ( +
    + {value.length > 1 && ( +
    + +
    + )} + + + + + + confirm({ + title: "Delete image?", + body: "This image cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: () => + handleDelete(image), + }) + } + > + + + + + + window.open(image.downloadURL, "_blank") + } + > + + + + + +
    + )} +
    + )} + + ))} + {localImages && + localImages.map((image) => ( + + - - - confirm({ - title: "Delete image?", - body: "This image cannot be recovered after", - confirm: "Delete", - confirmColor: "error", - handleConfirm: () => handleDelete(image), - }) - } + {uploaderState[image.name] && ( + - - - - - - window.open(image.downloadURL, "_blank") - } - > - - - - -
    -
    - )} -
    - ))} - - {localImages && - localImages.map((image) => ( - - - {uploaderState[image.name] && ( - - + + + )} + - )} - + ))} + {provided.placeholder}
    - ))} - + )} + + ); } diff --git a/src/components/fields/Percentage/DisplayCell.tsx b/src/components/fields/Percentage/DisplayCell.tsx index 532103e45..a6e5700a1 100644 --- a/src/components/fields/Percentage/DisplayCell.tsx +++ b/src/components/fields/Percentage/DisplayCell.tsx @@ -2,6 +2,7 @@ import { IDisplayCellProps } from "@src/components/fields/types"; import { useTheme } from "@mui/material"; import { resultColorsScale } from "@src/utils/color"; +import { multiply100WithPrecision } from "./utils"; export default function Percentage({ column, value }: IDisplayCellProps) { const theme = useTheme(); @@ -34,7 +35,7 @@ export default function Percentage({ column, value }: IDisplayCellProps) { zIndex: 1, }} > - {Math.round(percentage * 100)}% + {multiply100WithPrecision(percentage)}%
    ); diff --git a/src/components/fields/Percentage/EditorCell.tsx b/src/components/fields/Percentage/EditorCell.tsx index 1e3a7e96b..54247f558 100644 --- a/src/components/fields/Percentage/EditorCell.tsx +++ b/src/components/fields/Percentage/EditorCell.tsx @@ -1,13 +1,20 @@ import type { IEditorCellProps } from "@src/components/fields/types"; import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField"; +import { multiply100WithPrecision, divide100WithPrecision } from "./utils"; export default function Percentage(props: IEditorCellProps) { return ( props.onChange(Number(v) / 100)} + value={ + typeof props.value === "number" + ? multiply100WithPrecision(props.value) + : props.value + } + onChange={(v) => { + props.onChange(divide100WithPrecision(Number(v))); + }} /> ); } diff --git a/src/components/fields/Percentage/Settings.tsx b/src/components/fields/Percentage/Settings.tsx index e28dde2cc..b2552fc27 100644 --- a/src/components/fields/Percentage/Settings.tsx +++ b/src/components/fields/Percentage/Settings.tsx @@ -16,6 +16,7 @@ import { ISettingsProps } from "@src/components/fields/types"; import { Color, toColor } from "react-color-palette"; import { fieldSx } from "@src/components/SideDrawer/utils"; import { resultColorsScale, defaultColors } from "@src/utils/color"; +import { multiply100WithPrecision } from "./utils"; const colorLabels: { [key: string]: string } = { 0: "Start", @@ -160,7 +161,7 @@ const Preview = ({ colors }: { colors: any }) => { }} /> - {Math.floor(value * 100)}% + {multiply100WithPrecision(value)}% ); diff --git a/src/components/fields/Percentage/SideDrawerField.tsx b/src/components/fields/Percentage/SideDrawerField.tsx index a6cc43662..feaf5673c 100644 --- a/src/components/fields/Percentage/SideDrawerField.tsx +++ b/src/components/fields/Percentage/SideDrawerField.tsx @@ -3,6 +3,7 @@ import { ISideDrawerFieldProps } from "@src/components/fields/types"; import { TextField, InputAdornment, Box, useTheme } from "@mui/material"; import { resultColorsScale } from "@src/utils/color"; import { getFieldId } from "@src/components/SideDrawer/utils"; +import { multiply100WithPrecision } from "./utils"; export default function Percentage({ column, @@ -20,7 +21,9 @@ export default function Percentage({ margin="none" onChange={(e) => onChange(Number(e.target.value) / 100)} onBlur={onSubmit} - value={typeof value === "number" ? value * 100 : value} + value={ + typeof value === "number" ? multiply100WithPrecision(value) : value + } id={getFieldId(column.key)} label="" hiddenLabel diff --git a/src/components/fields/Percentage/utils.ts b/src/components/fields/Percentage/utils.ts new file mode 100644 index 000000000..f9e4ee25d --- /dev/null +++ b/src/components/fields/Percentage/utils.ts @@ -0,0 +1,126 @@ +import { trim, trimEnd } from "lodash-es"; + +/** + * Multiply a number by 100 and return a string without floating point error + * by shifting the decimal point 2 places to the right as a string + * e.g. floating point error: 0.07 * 100 === 7.000000000000001 + * + * A few examples: + * + * let number = 0.07; + * console.log(number, multiply100WithPrecision(number)); + * --> 7 + * + * number = 0; + * console.log(number, multiply100WithPrecision(number)); + * --> 0 + * + * number = 0.1; + * console.log(number, multiply100WithPrecision(number)); + * --> 10 + * + * number = 0.001; + * console.log(number, multiply100WithPrecision(number)); + * --> 0.1 + * + * number = 0.00001; + * console.log(number, multiply100WithPrecision(number)); + * --> 0.001 + * + * number = 100; + * console.log(number, multiply100WithPrecision(number)); + * --> 10000 + * + * number = 1999.99; + * console.log(number, multiply100WithPrecision(number)); + * --> 199999 + * + * number = 1999.999; + * console.log(number, multiply100WithPrecision(number)); + * --> 199999.9 + * + * number = 0.25; + * console.log(number, multiply100WithPrecision(number)); + * --> 25 + * + * number = 0.15; + * console.log(number, multiply100WithPrecision(number)); + * --> 15 + * + * number = 1.23456789; + * console.log(number, multiply100WithPrecision(number)); + * --> 123.456789 + * + * number = 0.0000000001; + * console.log(number, multiply100WithPrecision(number)); + * --> 1e-8 + */ +export const multiply100WithPrecision = (value: number) => { + if (value === 0) { + return 0; + } + + let valueString = value.toString(); + + // e.g 1e-10 becomes 1e-8 + if (valueString.includes("e")) { + return value * 100; + } + + // if the number is integer, add .00 + if (!valueString.includes(".")) { + valueString = valueString.concat(".00"); + } + + let [before, after] = valueString.split("."); + + // if after decimal has only 1 digit, pad a 0 + if (after.length === 1) { + after = after.concat("0"); + } + + let newNumber = `${before}${after.slice(0, 2)}.${after.slice(2)}`; + newNumber = trimEnd(trim(newNumber, "0"), "."); + if (newNumber.startsWith(".")) { + newNumber = "0" + newNumber; + } + return Number(newNumber); +}; + +/** + * Divide a number by 100 and return a string without floating point error + * by shifting the decimal point 2 places to the left as a string + */ +export const divide100WithPrecision = (value: number) => { + if (value === 0) { + return 0; + } + + let valueString = value.toString(); + + // e.g 1e-10 becomes 1e-8 + if (valueString.includes("e")) { + return value / 100; + } + + // add decimal if integer + if (!valueString.includes(".")) { + valueString = valueString + "."; + } + + let [before, after] = valueString.split("."); + + // if before decimal has less than digit, pad 0 + if (before.length < 2) { + before = "00" + before; + } + + let newNumber = `${before.slice(0, before.length - 2)}.${before.slice( + before.length - 2 + )}${after}`; + newNumber = trimEnd(trimEnd(newNumber, "0"), "."); + if (newNumber.startsWith(".")) { + newNumber = "0" + newNumber; + } + return Number(newNumber); +}; diff --git a/src/components/fields/Reference/filters.ts b/src/components/fields/Reference/filters.ts new file mode 100644 index 000000000..8f92284ed --- /dev/null +++ b/src/components/fields/Reference/filters.ts @@ -0,0 +1,6 @@ +import { DocumentReference } from "@google-cloud/firestore"; + +export const valueFormatter = (value: DocumentReference, operator: string) => { + if (value && value.path) return value.path; + return ""; +}; diff --git a/src/components/fields/Reference/index.tsx b/src/components/fields/Reference/index.tsx index c4fdce528..0dd1c6976 100644 --- a/src/components/fields/Reference/index.tsx +++ b/src/components/fields/Reference/index.tsx @@ -6,6 +6,7 @@ import { Reference } from "@src/assets/icons"; import DisplayCell from "./DisplayCell"; import EditorCell from "./EditorCell"; import { filterOperators } from "@src/components/fields/ShortText/Filter"; +import { valueFormatter } from "./filters"; const SideDrawerField = lazy( () => @@ -27,6 +28,6 @@ export const config: IFieldConfig = { disablePadding: true, }), SideDrawerField, - filter: { operators: filterOperators }, + filter: { operators: filterOperators, valueFormatter: valueFormatter }, }; export default config; diff --git a/src/components/fields/SingleSelect/Settings.tsx b/src/components/fields/SingleSelect/Settings.tsx index 2fc908b9a..c028f8748 100644 --- a/src/components/fields/SingleSelect/Settings.tsx +++ b/src/components/fields/SingleSelect/Settings.tsx @@ -15,6 +15,24 @@ import { import AddIcon from "@mui/icons-material/AddCircle"; import RemoveIcon from "@mui/icons-material/CancelRounded"; +import { + DragDropContext, + Draggable, + DraggingStyle, + Droppable, + NotDraggingStyle, +} from "react-beautiful-dnd"; +import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined"; + +const getItemStyle = ( + isDragging: boolean, + draggableStyle: DraggingStyle | NotDraggingStyle | undefined +) => ({ + backgroundColor: isDragging ? "rgba(255, 255, 255, 0.08)" : "", + borderRadius: "4px", + ...draggableStyle, +}); + export default function Settings({ onChange, config }: ISettingsProps) { const listEndRef: any = useRef(null); const options = config.options ?? []; @@ -31,6 +49,13 @@ export default function Settings({ onChange, config }: ISettingsProps) { } }; + const handleOnDragEnd = (result: any) => { + if (!result.destination) return; + const [removed] = options.splice(result.source.index, 1); + options.splice(result.destination.index, 0, removed); + onChange("options")([...options]); + }; + return (
    Options @@ -42,34 +67,65 @@ export default function Settings({ onChange, config }: ISettingsProps) { marginBottom: 5, }} > - {options?.map((option: string) => ( - <> - - - {option} - - - - onChange("options")( - options.filter((o: string) => o !== option) - ) - } - > - {} - - - - - - ))} + + + {(provided) => ( +
    + {options?.map((option: string, index: number) => ( + + {(provided, snapshot) => ( + <> + + + + {option} + + + + onChange("options")( + options.filter((o: string) => o !== option) + ) + } + > + {} + + + + + + )} + + ))} + {provided.placeholder} +
    + )} +
    +
    @@ -93,7 +149,7 @@ export default function Settings({ onChange, config }: ISettingsProps) { onChange={(e) => { setNewOption(e.target.value); }} - onKeyDown={(e: any) => { + onKeyPress={(e: any) => { if (e.key === "Enter") { handleAdd(); } diff --git a/src/components/fields/User/DisplayCell.tsx b/src/components/fields/User/DisplayCell.tsx index d299b6c5a..2b059ed9a 100644 --- a/src/components/fields/User/DisplayCell.tsx +++ b/src/components/fields/User/DisplayCell.tsx @@ -1,30 +1,111 @@ +import { useAtom } from "jotai"; +import { Avatar, AvatarGroup, ButtonBase, Stack, Tooltip } from "@mui/material"; +import { allUsersAtom, projectScope } from "@src/atoms/projectScope"; import { IDisplayCellProps } from "@src/components/fields/types"; +import { ChevronDown } from "@src/assets/icons/ChevronDown"; +import { UserDataType } from "./UserSelect"; -import { Tooltip, Stack, Avatar } from "@mui/material"; +export default function User({ + value, + showPopoverCell, + disabled, + tabIndex, +}: IDisplayCellProps) { + const [users] = useAtom(allUsersAtom, projectScope); -import { format } from "date-fns"; -import { DATE_TIME_FORMAT } from "@src/constants/dates"; + let userValue: UserDataType[] = []; + let emails = new Set(); -export default function User({ value, column }: IDisplayCellProps) { - if (!value || !value.displayName) return null; + if (value !== undefined && value !== null) { + if (!Array.isArray(value)) { + value = [value.email]; + } + for (const user of users) { + if (user.user && user.user?.email && value.includes(user.user.email)) { + if (!emails.has(user.user.email)) { + emails.add(user.user.email); + userValue.push(user.user); + } + } + } + } - const chip = ( - - - {value.displayName} + if (userValue.length === 0) { + return ( + showPopoverCell(true)} + style={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-end", + }} + tabIndex={tabIndex} + > + + + ); + } + + const rendered = ( + + {userValue.length > 1 ? ( + + {userValue.map((user: UserDataType) => ( + + + + ))} + + ) : ( + <> + + {userValue[0].displayName} + + )} ); - if (!value.timestamp) return chip; - - const dateLabel = format( - value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, - column.config?.format || DATE_TIME_FORMAT + if (disabled) { + return rendered; + } + return ( + showPopoverCell(true)} + style={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + tabIndex={tabIndex} + > + {rendered} + + ); - - return {chip}; } diff --git a/src/components/fields/User/EditorCell.tsx b/src/components/fields/User/EditorCell.tsx new file mode 100644 index 000000000..a65101dd9 --- /dev/null +++ b/src/components/fields/User/EditorCell.tsx @@ -0,0 +1,6 @@ +import { IEditorCellProps } from "@src/components/fields/types"; +import UserSelect from "./UserSelect"; + +export default function EditorCell({ ...props }: IEditorCellProps) { + return ; +} diff --git a/src/components/fields/User/Settings.tsx b/src/components/fields/User/Settings.tsx new file mode 100644 index 000000000..d1e6d3c8c --- /dev/null +++ b/src/components/fields/User/Settings.tsx @@ -0,0 +1,25 @@ +import { Typography, FormControlLabel, Checkbox } from "@mui/material"; +import { ISettingsProps } from "@src/components/fields/types"; + +export default function Settings({ onChange, config }: ISettingsProps) { + return ( + + Accept multiple value + + Make this column to support multiple values. + + + } + control={ + onChange("multiple")(e.target.checked)} + name="multiple" + /> + } + /> + ); +} diff --git a/src/components/fields/User/SideDrawerField.tsx b/src/components/fields/User/SideDrawerField.tsx index 1c21792a6..8187bc83e 100644 --- a/src/components/fields/User/SideDrawerField.tsx +++ b/src/components/fields/User/SideDrawerField.tsx @@ -1,50 +1,101 @@ -import { format } from "date-fns"; -import { ISideDrawerFieldProps } from "@src/components/fields/types"; - -import { Box, Stack, Typography, Avatar } from "@mui/material"; +import { useRef, useState } from "react"; +import { useAtom } from "jotai"; +import { Tooltip, Stack, AvatarGroup, Avatar } from "@mui/material"; -import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils"; -import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import { allUsersAtom, projectScope } from "@src/atoms/projectScope"; +import { fieldSx } from "@src/components/SideDrawer/utils"; +import { ChevronDown } from "@src/assets/icons/ChevronDown"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; +import UserSelect, { UserDataType } from "./UserSelect"; -export default function User({ +export default function SideDrawerSelect({ column, - _rowy_ref, value, - onDirty, onChange, onSubmit, disabled, }: ISideDrawerFieldProps) { - if (!value || !value.displayName || !value.timestamp) - return ; + const [open, setOpen] = useState(false); + const [users] = useAtom(allUsersAtom, projectScope); + const parentRef = useRef(null); - const dateLabel = value.timestamp - ? format( - value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, - column.config?.format || DATE_TIME_FORMAT - ) - : null; + let userValue: UserDataType[] = []; + let emails = new Set(); - return ( - - + if (value !== undefined && value !== null) { + if (!Array.isArray(value)) { + value = [value.email]; + } + for (const user of users) { + if (user.user && user.user?.email && value.includes(user.user.email)) { + if (!emails.has(user.user.email)) { + emails.add(user.user.email); + userValue.push(user.user); + } + } + } + } - + setOpen(true)} + direction="row" + sx={[ + fieldSx, + { + alignItems: "center", + justifyContent: userValue.length > 0 ? "space-between" : "flex-end", + marginTop: "8px", + marginBottom: "8px", + }, + ]} > - {value.displayName} ({value.email}) - {dateLabel && ( - - {dateLabel} - + {userValue.length === 0 ? null : userValue.length > 1 ? ( + + {userValue.map( + (user: UserDataType) => + user && ( + + + + ) + )} + + ) : ( +
    + + {userValue[0].displayName} +
    )} -
    -
    + +
    + + ); } diff --git a/src/components/fields/User/UserSelect.tsx b/src/components/fields/User/UserSelect.tsx new file mode 100644 index 000000000..df9eac974 --- /dev/null +++ b/src/components/fields/User/UserSelect.tsx @@ -0,0 +1,165 @@ +import { useMemo } from "react"; +import { useAtom } from "jotai"; + +import MultiSelect from "@rowy/multiselect"; +import { + AutocompleteProps, + Avatar, + Box, + PopoverProps, + Stack, +} from "@mui/material"; +import { createFilterOptions } from "@mui/material/Autocomplete"; + +import { projectScope, allUsersAtom } from "@src/atoms/projectScope"; +import { ColumnConfig } from "@src/types/table"; + +export type UserDataType = { + email: string; + displayName?: string; + photoURL?: string; + phoneNumber?: string; +}; + +type UserOptionType = { + label: string; + value: string; + user: UserDataType; +}; + +interface IUserSelectProps { + open?: boolean; + value: T; + onChange: (value: T) => void; + onSubmit: () => void; + parentRef?: PopoverProps["anchorEl"]; + column: ColumnConfig; + disabled: boolean; + showPopoverCell: (value: boolean) => void; +} + +export default function UserSelect({ + open, + value, + onChange, + onSubmit, + parentRef, + column, + showPopoverCell, + disabled, +}: IUserSelectProps) { + const [users] = useAtom(allUsersAtom, projectScope); + + const options = useMemo(() => { + let options: UserOptionType[] = []; + let emails = new Set(); + for (const user of users) { + if (user.user && user.user?.email) { + if (!emails.has(user.user.email)) { + emails.add(user.user.email); + options.push({ + label: user.user.email, + value: user.user.email, + user: user.user, + }); + } + } + } + return options; + }, [users]); + + const filterOptions = createFilterOptions({ + trim: true, + ignoreCase: true, + matchFrom: "start", + stringify: (option: UserOptionType) => option.user.displayName || "", + }); + + const renderOption: AutocompleteProps< + UserOptionType, + false, + false, + false + >["renderOption"] = (props, option) => { + return ; + }; + + if (value === undefined || value === null) { + value = []; + } else if (!Array.isArray(value)) { + value = [value.email]; + } + + return ( + { + if (typeof v === "string") { + v = [v]; + } + onChange(v); + }} + disabled={disabled} + clearText="Clear" + doneText="Done" + {...{ + AutocompleteProps: { + renderOption, + filterOptions, + }, + }} + onClose={() => { + onSubmit(); + showPopoverCell(false); + }} + // itemRenderer={(option: UserOptionType) => } + TextFieldProps={{ + style: { display: "none" }, + SelectProps: { + open: open === undefined ? true : open, + MenuProps: { + anchorEl: parentRef || null, + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + transformOrigin: { vertical: "top", horizontal: "center" }, + sx: { + "& .MuiPaper-root": { minWidth: `${column.width}px !important` }, + }, + }, + }, + }} + /> + ); +} + +const UserListItem = ({ user, ...props }: { user: UserDataType }) => { + return ( +
  • + + + + {user.displayName ? user.displayName[0] : ""} + + {user.displayName} + + +
  • + ); +}; diff --git a/src/components/fields/User/index.tsx b/src/components/fields/User/index.tsx index b06680df2..f7d42921f 100644 --- a/src/components/fields/User/index.tsx +++ b/src/components/fields/User/index.tsx @@ -4,14 +4,14 @@ import withRenderTableCell from "@src/components/Table/TableCell/withRenderTable import UserIcon from "@mui/icons-material/PersonOutlined"; import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; const SideDrawerField = lazy( () => import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-User" */) ); const Settings = lazy( - () => - import("../CreatedBy/Settings" /* webpackChunkName: "Settings-CreatedBy" */) + () => import("./Settings" /* webpackChunkName: "Settings-User" */) ); export const config: IFieldConfig = { @@ -23,7 +23,10 @@ export const config: IFieldConfig = { initialValue: null, icon: , description: "User information and optionally, timestamp. Read-only.", - TableCell: withRenderTableCell(DisplayCell, null), + TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", { + disablePadding: true, + transparentPopover: true, + }), SideDrawerField, settings: Settings, }; diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts index d5288ef0d..4b16d1f2f 100644 --- a/src/components/fields/index.ts +++ b/src/components/fields/index.ts @@ -31,8 +31,10 @@ import ConnectTable from "./ConnectTable"; import ConnectService from "./ConnectService"; import Json from "./Json"; import Code from "./Code"; +import Array from "./Array"; import Action from "./Action"; import Derivative from "./Derivative"; +import Formula from "./Formula"; import Markdown from "./Markdown"; // // import Aggregate from "./Aggregate"; import Status from "./Status"; @@ -81,11 +83,14 @@ export const FIELDS: IFieldConfig[] = [ Json, Code, Markdown, + Array, /** CLOUD FUNCTION */ Action, Derivative, // // Aggregate, Status, + /** CLIENT FUNCTION */ + Formula, /** AUDITING */ CreatedBy, UpdatedBy, diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts index 63374d811..a11e1e10f 100644 --- a/src/components/fields/types.ts +++ b/src/components/fields/types.ts @@ -19,6 +19,7 @@ export interface IFieldConfig { dataType: string; initializable?: boolean; requireConfiguration?: boolean; + requireCloudFunction?: boolean; initialValue: any; icon?: React.ReactNode; description?: string; diff --git a/src/constants/externalLinks.ts b/src/constants/externalLinks.ts index 4625d6bac..a2b64de4b 100644 --- a/src/constants/externalLinks.ts +++ b/src/constants/externalLinks.ts @@ -52,6 +52,8 @@ const WIKI_PATHS = { fieldTypesAction: "/field-types/action", fieldTypesAdd: "/field-types/add", + fieldTypesFormula: "/field-types/formula", + rowyRun: "/rowy-run", extensions: "/extensions", @@ -65,6 +67,7 @@ const WIKI_PATHS = { importAirtable: "/import-export-data/import-airtable", importAirtableApiKey: "/import-export-data/import-airtable#api-key", importAirtableTableUrl: "/import-export-data/import-airtable#table-url", + cloudLogs: "/cloud-logs", }; export const WIKI_LINKS = mapValues( WIKI_PATHS, diff --git a/src/constants/fields.ts b/src/constants/fields.ts index 4f6919864..b05c6b896 100644 --- a/src/constants/fields.ts +++ b/src/constants/fields.ts @@ -35,18 +35,20 @@ export enum FieldType { json = "JSON", code = "CODE", markdown = "MARKDOWN", + array = "ARRAY", // CLOUD FUNCTION action = "ACTION", derivative = "DERIVATIVE", aggregate = "AGGREGATE", status = "STATUS", + // CLIENT FUNCTION + formula = "FORMULA", // AUDIT createdBy = "CREATED_BY", updatedBy = "UPDATED_BY", createdAt = "CREATED_AT", updatedAt = "UPDATED_AT", // METADATA - user = "USER", id = "ID", last = "LAST", diff --git a/src/hooks/useFirestoreDocWithAtom.ts b/src/hooks/useFirestoreDocWithAtom.ts index 497366007..c5c2b51cc 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,11 @@ export function useFirestoreDocWithAtom( } } - return setDoc(memoizedDocRef, updateToDb, { merge: true }); + return setDoc(memoizedDocRef, updateToDb, { merge: true }).catch( + (e) => { + enqueueSnackbar((e as Error).message, { variant: "error" }); + } + ); }); } @@ -154,7 +161,7 @@ export function useFirestoreDocWithAtom( // reset the atom’s value to prevent writes if (updateDataAtom) setUpdateDataAtom(undefined); }; - }, [memoizedDocRef, updateDataAtom, setUpdateDataAtom]); + }, [memoizedDocRef, updateDataAtom, setUpdateDataAtom, enqueueSnackbar]); } export default useFirestoreDocWithAtom; @@ -163,12 +170,12 @@ export default useFirestoreDocWithAtom; * Create the Firestore document reference. * Put code in a function so the results can be compared by useMemoValue. */ -const getDocRef = ( +export const getDocRef = ( firebaseDb: Firestore, path: string | undefined, pathSegments?: Array ) => { - if (!path || (Array.isArray(pathSegments) && pathSegments.some((x) => !x))) + if (!path || (Array.isArray(pathSegments) && pathSegments?.some((x) => !x))) return null; return doc( diff --git a/src/layouts/AuthLayout.tsx b/src/layouts/AuthLayout.tsx index 031056c16..061f121bb 100644 --- a/src/layouts/AuthLayout.tsx +++ b/src/layouts/AuthLayout.tsx @@ -105,18 +105,20 @@ export default function AuthLayout({ display: "flex", flexDirection: "column", + justifyContent: "center", + alignContent: "center", "& > :not(style) + :not(style)": { mt: 4 }, } as any } > {title && ( - + {title} )} {description && ( - + {description} )} @@ -126,6 +128,7 @@ export default function AuthLayout({ justifyContent="center" alignItems="flex-start" style={{ flexGrow: 1 }} + margin="auto" > {children} @@ -146,6 +149,7 @@ export default function AuthLayout({ Project: {projectId} diff --git a/src/pages/Table/TablePage.tsx b/src/pages/Table/TablePage.tsx index 75f1fc281..0014ab810 100644 --- a/src/pages/Table/TablePage.tsx +++ b/src/pages/Table/TablePage.tsx @@ -33,6 +33,7 @@ import { tableSchemaAtom, columnModalAtom, tableModalAtom, + tableSortsAtom, } from "@src/atoms/tableScope"; import useBeforeUnload from "@src/hooks/useBeforeUnload"; import ActionParamsProvider from "@src/components/fields/Action/FormDialog/Provider"; @@ -81,8 +82,10 @@ export default function TablePage({ // Set permissions here so we can pass them to the `Table` component, which // shouldn’t access `projectScope` at all, to separate concerns. - const canAddColumns = - userRoles.includes("ADMIN") || userRoles.includes("OPS"); + const canAddColumns = Boolean( + userRoles.includes("ADMIN") || + tableSettings.modifiableBy?.some((r) => userRoles.includes(r)) + ); const canEditColumns = canAddColumns; const canDeleteColumns = canAddColumns; const canEditCells = diff --git a/src/sources/ProjectSourceFirebase/useTableFunctions.ts b/src/sources/ProjectSourceFirebase/useTableFunctions.ts index 95bbdcb5b..cceaca983 100644 --- a/src/sources/ProjectSourceFirebase/useTableFunctions.ts +++ b/src/sources/ProjectSourceFirebase/useTableFunctions.ts @@ -1,7 +1,15 @@ import { useEffect, useCallback } from "react"; import { useAtom, useSetAtom } from "jotai"; import { useAtomCallback } from "jotai/utils"; -import { doc, getDoc, setDoc, deleteDoc } from "firebase/firestore"; +import { + doc, + getDoc, + setDoc, + deleteDoc, + collection, + getDocs, + writeBatch, +} from "firebase/firestore"; import { camelCase, find, findIndex, isEmpty } from "lodash-es"; import { @@ -23,7 +31,7 @@ import { TABLE_GROUP_SCHEMAS, } from "@src/config/dbPaths"; import { rowyUser } from "@src/utils/table"; -import { TableSettings, TableSchema } from "@src/types/table"; +import { TableSettings, TableSchema, SubTablesSchema } from "@src/types/table"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; @@ -84,7 +92,7 @@ export function useTableFunctions() { if ( checked && // Make sure we don’t have - !Object.values(columns).some((column) => column.type === type) + !Object.values(columns)?.some((column) => column.type === type) ) columns["_" + camelCase(type)] = { type, @@ -104,6 +112,32 @@ export function useTableFunctions() { { merge: true } ); + // adding subtables + const batch = writeBatch(firebaseDb); + + if (_schema?.subTables) { + const subTableCollectionRef = (id: string) => + doc( + firebaseDb, + settings.tableType !== "collectionGroup" + ? TABLE_SCHEMAS + : TABLE_GROUP_SCHEMAS, + settings.id, + "subTables", + id + ); + Object.keys(_schema.subTables).forEach((subTableId: string) => { + if (_schema.subTables) { + batch.set( + subTableCollectionRef(subTableId), + _schema.subTables[subTableId] + ); + } + }); + + delete _schema.subTables; + } + // Creates schema doc with columns const { functionConfigPath, functionBuilderRef, ...schemaToWrite } = _schema ?? {}; @@ -121,7 +155,11 @@ export function useTableFunctions() { ); // Wait for both to complete - await Promise.all([promiseUpdateSettings, promiseAddSchema]); + await Promise.all([ + promiseUpdateSettings, + promiseAddSchema, + batch.commit(), + ]); } ); }, [currentUser, firebaseDb, readTables, setCreateTable]); @@ -145,13 +183,54 @@ export function useTableFunctions() { // Shallow merge new settings with old tables[tableIndex] = { ...tables[tableIndex], ...settings }; + // Create tablesSettings object from tables array + const tablesSettings = tables.reduce( + (acc, table) => { + if (table.tableType === "primaryCollection") { + acc.pc[table.id] = table; + } else { + acc.cg[table.id] = table; + } + return acc; + }, + { + pc: {}, + cg: {}, + } as Record> + ); // Updates settings doc with new tables array const promiseUpdateSettings = setDoc( doc(firebaseDb, SETTINGS), - { tables }, + { tables, tablesSettings }, { merge: true } ); + // adding subtables + const batch = writeBatch(firebaseDb); + + if (_schema?.subTables) { + const subTableCollectionRef = (id: string) => + doc( + firebaseDb, + settings.tableType !== "collectionGroup" + ? TABLE_SCHEMAS + : TABLE_GROUP_SCHEMAS, + settings.id, + "subTables", + id + ); + Object.keys(_schema.subTables).forEach((subTableId: string) => { + if (_schema.subTables) { + batch.set( + subTableCollectionRef(subTableId), + _schema.subTables[subTableId] + ); + } + }); + + delete _schema.subTables; + } + // Updates schema doc if param is provided const { functionConfigPath, functionBuilderRef, ...schemaToWrite } = _schema ?? {}; @@ -167,7 +246,11 @@ export function useTableFunctions() { : await setDoc(tableSchemaDocRef, schemaToWrite, { merge: true }); // Wait for both to complete - await Promise.all([promiseUpdateSettings, promiseUpdateSchema]); + await Promise.all([ + promiseUpdateSettings, + promiseUpdateSchema, + batch.commit(), + ]); } ); }, [firebaseDb, readTables, setUpdateTable]); @@ -205,7 +288,7 @@ export function useTableFunctions() { // Set the getTableSchema function const setGetTableSchema = useSetAtom(getTableSchemaAtom, projectScope); useEffect(() => { - setGetTableSchema(() => async (id: string) => { + setGetTableSchema(() => async (id: string, withSubtables?: boolean) => { // Get latest tables const tables = (await readTables()) || []; const table = find(tables, ["id", id]); @@ -217,9 +300,34 @@ export function useTableFunctions() { : TABLE_SCHEMAS, id ); - return getDoc(tableSchemaDocRef).then( - (doc) => (doc.data() || {}) as TableSchema - ); + + let tableSchema: TableSchema | Promise = getDoc( + tableSchemaDocRef + ).then((doc) => (doc.data() || {}) as TableSchema); + + if (withSubtables) { + let subTables: SubTablesSchema | Promise = getDocs( + collection( + firebaseDb, + `${ + table?.tableType === "collectionGroup" + ? TABLE_GROUP_SCHEMAS + : TABLE_SCHEMAS + }/${id}/subTables` + ) + ).then((querySnapshot) => { + let subTables: SubTablesSchema = {}; + querySnapshot.forEach((doc) => { + subTables[doc.id] = doc.data(); + }); + return subTables; + }); + + [tableSchema, subTables] = await Promise.all([tableSchema, subTables]); + tableSchema.subTables = subTables; + } + + return tableSchema as TableSchema; }); }, [firebaseDb, readTables, setGetTableSchema]); } diff --git a/src/sources/TableSourceFirestore/TableSourceFirestore.tsx b/src/sources/TableSourceFirestore/TableSourceFirestore.tsx index 557fb75e4..9f3e2cedf 100644 --- a/src/sources/TableSourceFirestore/TableSourceFirestore.tsx +++ b/src/sources/TableSourceFirestore/TableSourceFirestore.tsx @@ -1,6 +1,15 @@ -import { memo, useCallback } from "react"; -import { useAtom } from "jotai"; -import { FirestoreError } from "firebase/firestore"; +import { memo, useCallback, useEffect } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import useMemoValue from "use-memo-value"; +import { cloneDeep, set } from "lodash-es"; +import { + FirestoreError, + deleteField, + refEqual, + setDoc, +} from "firebase/firestore"; +import { useSnackbar } from "notistack"; +import { useErrorHandler } from "react-error-boundary"; import { tableScope, @@ -16,29 +25,81 @@ import { tableNextPageAtom, serverDocCountAtom, } from "@src/atoms/tableScope"; -import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom"; + +import useFirestoreDocWithAtom, { + getDocRef, +} from "@src/hooks/useFirestoreDocWithAtom"; import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom"; import useAuditChange from "./useAuditChange"; import useBulkWriteDb from "./useBulkWriteDb"; import { handleFirestoreError } from "./handleFirestoreError"; -import { useSnackbar } from "notistack"; -import { useErrorHandler } from "react-error-boundary"; import { getTableSchemaPath } from "@src/utils/table"; +import { TableSchema } from "@src/types/table"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; +import { projectScope } from "@src/atoms/projectScope"; /** * When rendered, provides atom values for top-level tables and sub-tables */ export const TableSourceFirestore = memo(function TableSourceFirestore() { - // Get tableSettings from tableId and tables in projectScope + const [firebaseDb] = useAtom(firebaseDbAtom, projectScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const setTableSchema = useSetAtom(tableSchemaAtom, tableScope); + const setUpdateTableSchema = useSetAtom(updateTableSchemaAtom, tableScope); + + const { enqueueSnackbar } = useSnackbar(); + if (!tableSettings) throw new Error("No table config"); if (!tableSettings.collection) throw new Error("Invalid table config: no collection"); + const tableSchemaDocRef = useMemoValue( + getDocRef(firebaseDb, getTableSchemaPath(tableSettings)), + (next, prev) => refEqual(next as any, prev as any) + ); const isCollectionGroup = tableSettings.tableType === "collectionGroup"; + useEffect(() => { + if (!tableSchemaDocRef) return; + + setUpdateTableSchema( + () => (update: TableSchema, deleteFields?: string[]) => { + const updateToDb = cloneDeep(update); + + if (Array.isArray(deleteFields)) { + for (const field of deleteFields) { + // Use deterministic set firestore sentinel's on schema columns config + // Required for nested columns + // i.e field = "columns.base.nested.nested" + // key: columns, rest: base.nested.nested + // set columns["base.nested.nested"] instead columns.base.nested.nested + const [key, ...rest] = field.split("."); + if (key === "columns") { + (updateToDb as any).columns[rest.join(".")] = deleteField(); + } else { + set(updateToDb, field, deleteField()); + } + } + } + + // Update UI state to reflect changes immediately to prevent flickering effects + setTableSchema((tableSchema) => ({ ...tableSchema, ...update })); + + return setDoc(tableSchemaDocRef, updateToDb, { merge: true }).catch( + (e) => { + enqueueSnackbar((e as Error).message, { variant: "error" }); + } + ); + } + ); + + return () => { + setUpdateTableSchema(undefined); + }; + }, [tableSchemaDocRef, setTableSchema, setUpdateTableSchema, enqueueSnackbar]); + // Get tableSchema and store in tableSchemaAtom. // If it doesn’t exist, initialize columns useFirestoreDocWithAtom( @@ -47,7 +108,6 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() { getTableSchemaPath(tableSettings), { createIfNonExistent: { columns: {} }, - updateDataAtom: updateTableSchemaAtom, disableSuspense: true, } ); @@ -58,7 +118,6 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() { const [page] = useAtom(tablePageAtom, tableScope); // Get documents from collection and store in tableRowsDbAtom // and handle some errors with snackbars - const { enqueueSnackbar } = useSnackbar(); const elevateError = useErrorHandler(); const handleErrorCallback = useCallback( (error: FirestoreError) => diff --git a/src/sources/TableSourceFirestore/handleFirestoreError.tsx b/src/sources/TableSourceFirestore/handleFirestoreError.tsx index 0dda67cb9..7476c9868 100644 --- a/src/sources/TableSourceFirestore/handleFirestoreError.tsx +++ b/src/sources/TableSourceFirestore/handleFirestoreError.tsx @@ -44,6 +44,28 @@ export const handleFirestoreError = ( return; } + if (error.message.includes("/firestore/indexes?")) { + enqueueSnackbar( + "Filtering on a group collection requires a new Firestore index", + { + variant: "warning", + action: ( + + ), + } + ); + return; + } + if (error.code === "invalid-argument") { enqueueSnackbar("Cannot sort by this column with the current set filters", { variant: "error", diff --git a/src/types/table.d.ts b/src/types/table.d.ts index b9753c41b..a05705422 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -75,6 +75,7 @@ export type TableSettings = { description?: string; details?: string; thumbnailURL?: string; + modifiableBy?: string[]; _createdBy?: { displayName?: string; @@ -100,6 +101,7 @@ export type TableSchema = { rowHeight?: number; filters?: TableFilter[]; filtersOverridable?: boolean; + sorts?: TableSort[]; functionConfigPath?: string; functionBuilderRef?: any; @@ -109,10 +111,15 @@ export type TableSchema = { webhooks?: IWebhook[]; runtimeOptions?: IRuntimeOptions; + subTables?: SubTablesSchema; /** @deprecated Migrate to Extensions */ sparks?: string; }; +export type SubTablesSchema = { + [key: string]: TableSchema; +}; + export type ColumnConfig = { /** Unique key for this column. Currently set to the same as fieldName */ key: string; diff --git a/yarn.lock b/yarn.lock index d669d494d..8ff8ab683 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8048,9 +8048,9 @@ json2csv@^5.0.7: lodash.get "^4.4.2" json5@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" - integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== dependencies: minimist "^1.2.0"