From e9bc4a5a9b84b42ec5fa88ba5fa98a43d817fdc0 Mon Sep 17 00:00:00 2001 From: Sidney Alcantara Date: Mon, 14 Nov 2022 16:09:01 +1100 Subject: [PATCH] Merge branch 'develop' into feature/rowy-706-table-upgrade --- .github/workflows/deploy-preview.yml | 4 +- src/atoms/tableScope/columnActions.ts | 43 ++- src/components/ColumnMenu/ColumnMenu.tsx | 80 ++++- src/components/ErrorFallback.tsx | 58 ++-- .../TableInformationDrawer/Details.tsx | 294 ++++++++++++------ .../ExportModal/ModalContentsDownload.tsx | 6 +- .../ExtensionsModal/ExtensionsModal.tsx | 80 +++-- .../ExtensionsModal/RuntimeOptions.tsx | 117 +++++++ .../TableModals/ExtensionsModal/utils.ts | 6 + .../TableSettingsDialog/TableDetails.tsx | 23 +- .../TableToolbar/TableToolbarButton.tsx | 41 ++- src/components/fields/Action/EditorCell.tsx | 11 +- src/components/fields/Action/Settings.tsx | 50 ++- .../fields/Action/SideDrawerField.tsx | 3 +- src/components/fields/Color/filters.ts | 21 ++ src/components/fields/Color/index.tsx | 5 + .../fields/Json/SideDrawerField.tsx | 5 +- src/components/fields/Rating/DisplayCell.tsx | 24 +- src/components/fields/Rating/Icon.tsx | 31 ++ src/components/fields/Rating/Settings.tsx | 33 +- .../fields/Rating/SideDrawerField.tsx | 6 +- src/constants/externalLinks.ts | 4 +- src/hooks/useFirestoreCollectionWithAtom.ts | 12 +- src/index.tsx | 10 +- src/pages/Table/ProvidedTablePage.tsx | 50 ++- src/theme/components.tsx | 3 +- src/types/table.d.ts | 16 +- 27 files changed, 789 insertions(+), 247 deletions(-) create mode 100644 src/components/TableModals/ExtensionsModal/RuntimeOptions.tsx create mode 100644 src/components/fields/Color/filters.ts create mode 100644 src/components/fields/Rating/Icon.tsx diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index ed7b8fd7..9ca97e44 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -8,7 +8,7 @@ on: env: REACT_APP_FIREBASE_PROJECT_ID: rowyio REACT_APP_FIREBASE_PROJECT_WEB_API_KEY: - "${{ secrets.FIREBASE_WEB_API_KEY_ROWYIO }}" + "${{ secrets.FIREBASE_WEB_API_KEY_TRYROWY }}" CI: "" jobs: build_and_preview: @@ -27,6 +27,6 @@ jobs: with: repoToken: "${{ secrets.GITHUB_TOKEN }}" firebaseServiceAccount: - "${{ secrets.FIREBASE_SERVICE_ACCOUNT_ROWYIO }}" + "${{ secrets.FIREBASE_SERVICE_ACCOUNT_TRYROWY }}" expires: 14d projectId: rowyio diff --git a/src/atoms/tableScope/columnActions.ts b/src/atoms/tableScope/columnActions.ts index 96a37d2a..2f3926e8 100644 --- a/src/atoms/tableScope/columnActions.ts +++ b/src/atoms/tableScope/columnActions.ts @@ -1,10 +1,12 @@ import { atom } from "jotai"; import { findIndex } from "lodash-es"; +import { FieldType } from "@src/constants/fields"; import { tableColumnsOrderedAtom, tableColumnsReducer, updateTableSchemaAtom, + tableSchemaAtom, } from "./table"; import { ColumnConfig } from "@src/types/table"; @@ -14,6 +16,7 @@ export interface IAddColumnOptions { /** Index to add column at. If undefined, adds to end */ index?: number; } + /** * Set function adds a column to tableSchema, to the end or by index. * Also fixes any issues with column indexes, so they go from 0 to length - 1 @@ -52,6 +55,7 @@ export interface IUpdateColumnOptions { /** If passed, reorders the column to the index */ index?: number; } + /** * Set function updates a column in tableSchema * @throws Error if column not found @@ -110,13 +114,50 @@ export const updateColumnAtom = atom( * ``` */ export const deleteColumnAtom = atom(null, async (get, _set, key: string) => { + const tableSchema = get(tableSchemaAtom); const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)]; const updateTableSchema = get(updateTableSchemaAtom); if (!updateTableSchema) throw new Error("Cannot update table schema"); const updatedColumns = tableColumnsOrdered .filter((c) => c.key !== key) + .map((c) => { + // remove column from derivatives listener fields + if (c.type === FieldType.derivative) { + return { + ...c, + config: { + ...c.config, + listenerFields: + c.config?.listenerFields?.filter((f) => f !== key) ?? [], + }, + }; + } else if (c.type === FieldType.action) { + return { + ...c, + config: { + ...c.config, + requiredFields: + c.config?.requiredFields?.filter((f) => f !== key) ?? [], + }, + }; + } else { + return c; + } + }) .reduce(tableColumnsReducer, {}); - await updateTableSchema({ columns: updatedColumns }, [`columns.${key}`]); + const updatedExtensionObjects = tableSchema?.extensionObjects?.map( + (extension) => { + return { + ...extension, + requiredFields: extension.requiredFields.filter((f) => f !== key), + }; + } + ); + + await updateTableSchema( + { columns: updatedColumns, extensionObjects: updatedExtensionObjects }, + [`columns.${key}`] + ); }); diff --git a/src/components/ColumnMenu/ColumnMenu.tsx b/src/components/ColumnMenu/ColumnMenu.tsx index 9d193179..54f6e0c3 100644 --- a/src/components/ColumnMenu/ColumnMenu.tsx +++ b/src/components/ColumnMenu/ColumnMenu.tsx @@ -7,6 +7,7 @@ import { ListItemIcon, ListItemText, Typography, + Divider, } from "@mui/material"; import FilterIcon from "@mui/icons-material/FilterList"; import LockOpenIcon from "@mui/icons-material/LockOpen"; @@ -50,12 +51,18 @@ import { columnModalAtom, tableFiltersPopoverAtom, tableNextPageAtom, + tableSchemaAtom, } from "@src/atoms/tableScope"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; import { analytics, logEvent } from "@src/analytics"; -import { formatSubTableName, getTableSchemaPath } from "@src/utils/table"; +import { + formatSubTableName, + getTableBuildFunctionPathname, + getTableSchemaPath, +} from "@src/utils/table"; import { runRoutes } from "@src/constants/runRoutes"; +import { useSnackLogContext } from "@src/contexts/SnackLogContext"; export interface IMenuModalProps { name: string; @@ -91,6 +98,8 @@ export default function ColumnMenu() { tableScope ); const [tableNextPage] = useAtom(tableNextPageAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const snackLogContext = useSnackLogContext(); const [altPress] = useAtom(altPressAtom, projectScope); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); @@ -117,8 +126,42 @@ export default function ColumnMenu() { const userDocHiddenFields = userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? []; + let referencedColumns: string[] = []; + let referencedExtensions: string[] = []; + Object.entries(tableSchema?.columns ?? {}).forEach(([key, c], index) => { + if ( + c.config?.listenerFields?.includes(column.key) || + c.config?.requiredFields?.includes(column.key) + ) { + referencedColumns.push(c.name); + } + }); + tableSchema?.extensionObjects?.forEach((extension) => { + if (extension.requiredFields.includes(column.key)) { + referencedExtensions.push(extension.name); + } + }); + const requireRebuild = + referencedColumns.length || referencedExtensions.length; + const handleDeleteColumn = () => { deleteColumn(column.key); + if (requireRebuild) { + snackLogContext.requestSnackLog(); + rowyRun({ + route: runRoutes.buildFunction, + body: { + tablePath: tableSettings.collection, + // pathname must match old URL format + pathname: getTableBuildFunctionPathname( + tableSettings.id, + tableSettings.tableType + ), + tableConfigPath: getTableSchemaPath(tableSettings), + }, + }); + logEvent(analytics, "deployed_extensions"); + } logEvent(analytics, "delete_column", { type: column.type }); handleClose(); }; @@ -360,8 +403,8 @@ export default function ColumnMenu() { icon: , onClick: altPress ? handleDeleteColumn - : () => - confirm({ + : () => { + return confirm({ title: "Delete column?", body: ( <> @@ -373,12 +416,39 @@ export default function ColumnMenu() { Key: {column.key} + {requireRebuild ? ( + <> + + {referencedColumns.length ? ( + + This column will be removed as a dependency of the + following columns:{" "} + + {referencedColumns.join(", ")} + + + ) : null} + {referencedExtensions.length ? ( + + This column will be removed as a dependency from the + following Extensions:{" "} + + {referencedExtensions.join(", ")} + + + ) : null} + + You need to re-deploy this table’s cloud function. + + + ) : null} ), - confirm: "Delete", + confirm: requireRebuild ? "Delete & re-deploy" : "Delete", confirmColor: "error", handleConfirm: handleDeleteColumn, - }), + }); + }, color: "error" as "error", }, ]; diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx index 83f8fb7a..3d7f130a 100644 --- a/src/components/ErrorFallback.tsx +++ b/src/components/ErrorFallback.tsx @@ -1,21 +1,17 @@ import { useState, useEffect } from "react"; import { FallbackProps } from "react-error-boundary"; -import { useLocation, Link } from "react-router-dom"; +import { useLocation } from "react-router-dom"; import useOffline from "@src/hooks/useOffline"; import { Typography, Button } from "@mui/material"; import ReloadIcon from "@mui/icons-material/Refresh"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; import OfflineIcon from "@mui/icons-material/CloudOff"; -import { Tables as TablesIcon } from "@src/assets/icons"; import EmptyState, { IEmptyStateProps } from "@src/components/EmptyState"; import AccessDenied from "@src/components/AccessDenied"; -import { ROUTES } from "@src/constants/routes"; -import meta from "@root/package.json"; - -export const ERROR_TABLE_NOT_FOUND = "Table not found"; +import { EXTERNAL_LINKS } from "@src/constants/externalLinks"; export interface IErrorFallbackProps extends FallbackProps, IEmptyStateProps {} @@ -43,9 +39,22 @@ export function ErrorFallbackContents({ - - ), - }; - } - } - if (error.message.startsWith("Loading chunk")) { if (isOffline) { renderProps = { Icon: OfflineIcon, message: "You’re offline" }; diff --git a/src/components/TableInformationDrawer/Details.tsx b/src/components/TableInformationDrawer/Details.tsx index 3f93721b..78dd0236 100644 --- a/src/components/TableInformationDrawer/Details.tsx +++ b/src/components/TableInformationDrawer/Details.tsx @@ -1,134 +1,244 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { format } from "date-fns"; -import { find } from "lodash-es"; +import { find, isEqual } from "lodash-es"; import MDEditor from "@uiw/react-md-editor"; -import { Box, IconButton, Stack, Typography } from "@mui/material"; +import { + Box, + IconButton, + Stack, + TextField, + Typography, + useTheme, +} from "@mui/material"; import EditIcon from "@mui/icons-material/EditOutlined"; +import EditOffIcon from "@mui/icons-material/EditOffOutlined"; import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; -import { useAtom, useSetAtom } from "jotai"; +import { useAtom } from "jotai"; import { projectScope, tablesAtom, - tableSettingsDialogAtom, + updateTableAtom, userRolesAtom, } from "@src/atoms/projectScope"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; +import SaveState from "@src/components/SideDrawer/SaveState"; export default function Details() { const [userRoles] = useAtom(userRolesAtom, projectScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const [tables] = useAtom(tablesAtom, projectScope); - const openTableSettingsDialog = useSetAtom( - tableSettingsDialogAtom, - projectScope - ); + const [updateTable] = useAtom(updateTableAtom, projectScope); + const theme = useTheme(); const settings = useMemo( () => find(tables, ["id", tableSettings.id]), [tables, tableSettings.id] ); + const { description, details, _createdBy } = settings ?? {}; + + const [editDescription, setEditDescription] = useState(false); + const [localDescription, setLocalDescription] = useState(description ?? ""); + const [localDetails, setLocalDetails] = useState(details ?? ""); + const [editDetails, setEditDetails] = useState(false); + const [mdFullScreen, setMdFullScreen] = useState(false); + + const [saveState, setSaveState] = useState< + "" | "unsaved" | "saving" | "saved" + >(""); + if (!settings) { return null; } - const editButton = userRoles.includes("ADMIN") && ( - - openTableSettingsDialog({ - mode: "update", - data: settings, - }) - } - disabled={!openTableSettingsDialog || settings.id.includes("/")} - > - - - ); + const handleSave = async () => { + setSaveState("saving"); + await updateTable!({ + ...settings, + description: localDescription, + details: localDetails, + }); + setSaveState("saved"); + }; - const { description, details, _createdBy } = settings; + const isAdmin = userRoles.includes("ADMIN"); return ( - .MuiGrid-root": { - position: "relative", - }, - }} - > - {/* Description */} - - - - Description - - {editButton} + <> + + + + + {/* Description */} + + + + Description + + {isAdmin && ( + { + setEditDescription(!editDescription); + }} + sx={{ top: 4 }} + > + {editDescription ? : } + + )} + + {editDescription ? ( + { + setLocalDescription(e.target.value); + saveState !== "unsaved" && setSaveState("unsaved"); + }} + onBlur={() => + isEqual(description, localDescription) + ? setSaveState("") + : handleSave() + } + rows={2} + minRows={2} + /> + ) : ( + + {localDescription ? localDescription : "No description"} + + )} - - {description ? description : "No description"} - - - - {/* Details */} - - - - Details - - {editButton} - - {!details ? ( - - No details - - ) : ( + {/* Details */} + + + + Details + + {isAdmin && ( + { + setEditDetails(!editDetails); + }} + sx={{ top: 4 }} + > + {editDetails ? : } + + )} + ul": { + display: "flex", + alignItems: "center", + }, + "& .w-md-editor-toolbar > ul:first-of-type": { + overflowX: "auto", + marginRight: theme.spacing(1), + }, + "& :is(h1, h2, h3, h4, h5, h6)": { + marginY: `${theme.spacing(1.5)} !important`, + borderBottom: "none !important", + }, + "& details summary": { + marginBottom: theme.spacing(1), + }, }} > - + {editDetails ? ( + { + if (command.name === "fullscreen") { + command.execute = () => setMdFullScreen(!mdFullScreen); + } + return command; + }} + textareaProps={{ + autoFocus: true, + onChange: (e) => { + setLocalDetails(e.target.value ?? ""); + saveState !== "unsaved" && setSaveState("unsaved"); + }, + onBlur: () => + isEqual(details, localDetails) + ? setSaveState("") + : handleSave(), + }} + /> + ) : !localDetails ? ( + No details + ) : ( + + )} + + {/* Table Audits */} + {_createdBy && ( + + + Created by{" "} + + {_createdBy.displayName} + {" "} + on{" "} + + {format(_createdBy.timestamp.toDate(), DATE_TIME_FORMAT)} + + + )} - - {/* Table Audits */} - {_createdBy && ( - - - Created by{" "} - - {_createdBy.displayName} - {" "} - on{" "} - - {format(_createdBy.timestamp.toDate(), DATE_TIME_FORMAT)} - - - - )} - + ); } diff --git a/src/components/TableModals/ExportModal/ModalContentsDownload.tsx b/src/components/TableModals/ExportModal/ModalContentsDownload.tsx index 468d52cc..8b6690dd 100644 --- a/src/components/TableModals/ExportModal/ModalContentsDownload.tsx +++ b/src/components/TableModals/ExportModal/ModalContentsDownload.tsx @@ -137,7 +137,11 @@ export default function Export({ x.key)} onChange={handleChange(setColumns)} - filterColumns={(column) => DOWNLOADABLE_COLUMNS.includes(column.type)} + filterColumns={(column) => + column.type === FieldType.derivative + ? DOWNLOADABLE_COLUMNS.includes(column.config?.renderFieldType) + : DOWNLOADABLE_COLUMNS.includes(column.type) + } label="Columns to export" labelPlural="columns" TextFieldProps={{ diff --git a/src/components/TableModals/ExtensionsModal/ExtensionsModal.tsx b/src/components/TableModals/ExtensionsModal/ExtensionsModal.tsx index 57f6fbee..71146804 100644 --- a/src/components/TableModals/ExtensionsModal/ExtensionsModal.tsx +++ b/src/components/TableModals/ExtensionsModal/ExtensionsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useAtom, useSetAtom } from "jotai"; -import { isEqual } from "lodash-es"; +import { isEqual, isUndefined } from "lodash-es"; import { ITableModalProps } from "@src/components/TableModals"; import Modal from "@src/components/Modal"; @@ -23,7 +23,6 @@ import { } from "@src/atoms/tableScope"; import { useSnackLogContext } from "@src/contexts/SnackLogContext"; -import { emptyExtensionObject, IExtension, ExtensionType } from "./utils"; import { runRoutes } from "@src/constants/runRoutes"; import { analytics, logEvent } from "@src/analytics"; import { @@ -31,6 +30,14 @@ import { getTableBuildFunctionPathname, } from "@src/utils/table"; +import { + emptyExtensionObject, + IExtension, + ExtensionType, + IRuntimeOptions, +} from "./utils"; +import RuntimeOptions from "./RuntimeOptions"; + export default function ExtensionsModal({ onClose }: ITableModalProps) { const [currentUser] = useAtom(currentUserAtom, projectScope); const [rowyRun] = useAtom(rowyRunAtom, projectScope); @@ -39,12 +46,25 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) { const [tableSchema] = useAtom(tableSchemaAtom, tableScope); const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope); - const currentExtensionObjects = (tableSchema.extensionObjects ?? - []) as IExtension[]; const [localExtensionsObjects, setLocalExtensionsObjects] = useState( - currentExtensionObjects + tableSchema.extensionObjects ?? [] ); + const [localRuntimeOptions, setLocalRuntimeOptions] = useState( + tableSchema.runtimeOptions ?? {} + ); + + const errors = { + runtimeOptions: { + timeoutSeconds: + !isUndefined(localRuntimeOptions.timeoutSeconds) && + !( + localRuntimeOptions.timeoutSeconds! > 0 && + localRuntimeOptions.timeoutSeconds! <= 540 + ), + }, + }; + const [openMigrationGuide, setOpenMigrationGuide] = useState(false); useEffect(() => { if (tableSchema.sparks) setOpenMigrationGuide(true); @@ -57,7 +77,9 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) { } | null>(null); const snackLogContext = useSnackLogContext(); - const edited = !isEqual(currentExtensionObjects, localExtensionsObjects); + const edited = + !isEqual(tableSchema.extensionObjects ?? [], localExtensionsObjects) || + !isEqual(tableSchema.runtimeOptions ?? {}, localRuntimeOptions); const handleClose = ( _setOpen: React.Dispatch> @@ -70,7 +92,8 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) { cancel: "Keep", handleConfirm: () => { _setOpen(false); - setLocalExtensionsObjects(currentExtensionObjects); + setLocalExtensionsObjects(tableSchema.extensionObjects ?? []); + setLocalRuntimeOptions(tableSchema.runtimeOptions ?? {}); onClose(); }, }); @@ -79,15 +102,18 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) { } }; - const handleSaveExtensions = async (callback?: Function) => { + const handleSave = async (callback?: Function) => { if (updateTableSchema) - await updateTableSchema({ extensionObjects: localExtensionsObjects }); + await updateTableSchema({ + extensionObjects: localExtensionsObjects, + runtimeOptions: localRuntimeOptions, + }); if (callback) callback(); onClose(); }; const handleSaveDeploy = async () => { - handleSaveExtensions(() => { + handleSave(() => { try { snackLogContext.requestSnackLog(); rowyRun({ @@ -132,6 +158,13 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) { setExtensionModal(null); }; + const handleUpdateRuntimeOptions = (update: IRuntimeOptions) => { + setLocalRuntimeOptions((runtimeOptions) => ({ + ...runtimeOptions, + ...update, + })); + }; + const handleUpdateActive = (index: number, active: boolean) => { setLocalExtensionsObjects( localExtensionsObjects.map((extensionObject, i) => { @@ -217,24 +250,31 @@ export default function ExtensionsModal({ onClose }: ITableModalProps) { /> } children={ - + <> + + + } actions={{ primary: { children: "Save & Deploy", onClick: handleSaveDeploy, - disabled: !edited, + disabled: !edited || errors.runtimeOptions.timeoutSeconds, }, secondary: { children: "Save", - onClick: () => handleSaveExtensions(), - disabled: !edited, + onClick: () => handleSave(), + disabled: !edited || errors.runtimeOptions.timeoutSeconds, }, }} /> diff --git a/src/components/TableModals/ExtensionsModal/RuntimeOptions.tsx b/src/components/TableModals/ExtensionsModal/RuntimeOptions.tsx new file mode 100644 index 00000000..90ca584c --- /dev/null +++ b/src/components/TableModals/ExtensionsModal/RuntimeOptions.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { useAtom, useSetAtom } from "jotai"; + +import { + Accordion, + AccordionDetails, + AccordionSummary, + Button, + Grid, + InputAdornment, + TextField, + Typography, +} from "@mui/material"; +import { ChevronDown } from "@src/assets/icons"; +import MultiSelect from "@rowy/multiselect"; + +import { + compatibleRowyRunVersionAtom, + projectScope, + rowyRunModalAtom, +} from "@src/atoms/projectScope"; + +import { IRuntimeOptions } from "./utils"; + +export default function RuntimeOptions({ + runtimeOptions, + handleUpdate, + errors, +}: { + runtimeOptions: IRuntimeOptions; + handleUpdate: (runtimeOptions: IRuntimeOptions) => void; + errors: { timeoutSeconds: boolean }; +}) { + const [compatibleRowyRunVersion] = useAtom( + compatibleRowyRunVersionAtom, + projectScope + ); + const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope); + + const [expanded, setExpanded] = useState(false); + + const isCompatibleRowyRun = compatibleRowyRunVersion({ minVersion: "1.6.4" }); + + return ( + + + ) : ( + + ) + } + onClick={() => + isCompatibleRowyRun + ? setExpanded(!expanded) + : openRowyRunModal({ + version: "1.6.4", + feature: "Runtime options", + }) + } + > + Runtime options + + + + + handleUpdate({ memory: value ?? "256MB" })} + multiple={false} + options={["128MB", "256MB", "512MB", "1GB", "2GB", "4GB", "8GB"]} + /> + + + + seconds + ), + }} + onChange={(e) => + !isNaN(Number(e.target.value)) && + handleUpdate({ + timeoutSeconds: Number(e.target.value), + }) + } + inputProps={{ + inputMode: "numeric", + }} + error={errors.timeoutSeconds} + helperText={ + errors.timeoutSeconds + ? "Timeout must be an integer between 1 and 540" + : "The maximum timeout that can be specified is 9 mins (540 seconds)" + } + /> + + + + + ); +} diff --git a/src/components/TableModals/ExtensionsModal/utils.ts b/src/components/TableModals/ExtensionsModal/utils.ts index 117ef381..28d24fda 100644 --- a/src/components/TableModals/ExtensionsModal/utils.ts +++ b/src/components/TableModals/ExtensionsModal/utils.ts @@ -52,6 +52,12 @@ export interface IExtension { trackedFields?: string[]; } +// https://firebase.google.com/docs/functions/manage-functions#set_runtime_options +export interface IRuntimeOptions { + memory?: "128MB" | "256MB" | "512MB" | "1GB" | "2GB" | "4GB" | "8GB"; + timeoutSeconds?: number; +} + export const triggerTypes: ExtensionTrigger[] = ["create", "update", "delete"]; const extensionBodyTemplate = { diff --git a/src/components/TableSettingsDialog/TableDetails.tsx b/src/components/TableSettingsDialog/TableDetails.tsx index 705233b8..c4cdbe05 100644 --- a/src/components/TableSettingsDialog/TableDetails.tsx +++ b/src/components/TableSettingsDialog/TableDetails.tsx @@ -14,19 +14,40 @@ export default function TableDetails({ ...props }) { {props.label ?? ""} ul": { + display: "flex", + alignItems: "center", + }, + "& .w-md-editor-toolbar > ul:first-of-type": { + overflowX: "auto", + marginRight: theme.spacing(1), + }, + "& :is(h1, h2, h3, h4, h5, h6)": { + marginY: `${theme.spacing(1.5)} !important`, + borderBottom: "none !important", + }, + "& details summary": { + marginBottom: theme.spacing(1), + }, }} > { title: string; icon: React.ReactNode; + tooltip?: string; } export const TableToolbarButton = forwardRef(function TableToolbarButton_( - { title, icon, ...props }: ITableToolbarButtonProps, + { title, icon, tooltip, ...props }: ITableToolbarButtonProps, ref: React.Ref ) { + // https://mui.com/material-ui/react-tooltip/#accessibility + const tooltipIsDescription = Boolean(tooltip); + + const button = ( + + ); + return ( - - - - + + {props.disabled ? {button} : button} ); }); diff --git a/src/components/fields/Action/EditorCell.tsx b/src/components/fields/Action/EditorCell.tsx index 1706c4e2..2f458e86 100644 --- a/src/components/fields/Action/EditorCell.tsx +++ b/src/components/fields/Action/EditorCell.tsx @@ -4,6 +4,15 @@ import { Stack } from "@mui/material"; import ActionFab from "./ActionFab"; import { sanitiseCallableName, isUrl } from "./utils"; +import { get } from "lodash-es"; + +export const getActionName = (column: any) => { + const config = get(column, "config"); + if (!get(config, "customName.enabled")) { + return get(column, "name"); + } + return get(config, "customName.actionName") || get(column, "name"); +}; export default function Action({ column, @@ -28,7 +37,7 @@ export default function Action({ ) : hasRan ? ( value.status ) : ( - sanitiseCallableName(column.key) + sanitiseCallableName(getActionName(column)) )} diff --git a/src/components/fields/Action/Settings.tsx b/src/components/fields/Action/Settings.tsx index 0c0655b1..a40a3c54 100644 --- a/src/components/fields/Action/Settings.tsx +++ b/src/components/fields/Action/Settings.tsx @@ -559,20 +559,46 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => { title: "Customization", content: ( <> - + + onChange("customName.enabled")(e.target.checked) + } + name="customName.enabled" + /> + } + label="Customize label for action" + style={{ marginLeft: -11 }} + /> + {config.customName?.enabled && ( + - onChange("customIcons.enabled")(e.target.checked) + onChange("customName.actionName")(e.target.value) } - name="customIcons.enabled" - /> - } - label="Customize button icons with emoji" - style={{ marginLeft: -11 }} - /> - + label="Action name:" + className="labelHorizontal" + inputProps={{ style: { width: "10ch" } }} + > + )} + + onChange("customIcons.enabled")(e.target.checked) + } + name="customIcons.enabled" + /> + } + label="Customize button icons with emoji" + style={{ marginLeft: -11 }} + /> + {config.customIcons?.enabled && ( diff --git a/src/components/fields/Action/SideDrawerField.tsx b/src/components/fields/Action/SideDrawerField.tsx index f17dcb7e..1f13e6d4 100644 --- a/src/components/fields/Action/SideDrawerField.tsx +++ b/src/components/fields/Action/SideDrawerField.tsx @@ -10,6 +10,7 @@ import ActionFab from "./ActionFab"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils"; import { sanitiseCallableName, isUrl } from "./utils"; +import { getActionName } from "./TableCell"; export default function Action({ column, @@ -58,7 +59,7 @@ export default function Action({ ) : hasRan ? ( value.status ) : ( - sanitiseCallableName(column.key) + sanitiseCallableName(getActionName(column)) )} diff --git a/src/components/fields/Color/filters.ts b/src/components/fields/Color/filters.ts new file mode 100644 index 00000000..0a2732be --- /dev/null +++ b/src/components/fields/Color/filters.ts @@ -0,0 +1,21 @@ +import { IFilterOperator } from "@src/components/fields/types"; + +export const filterOperators: IFilterOperator[] = [ + { + label: "is", + secondaryLabel: "==", + value: "color-equal", + }, + { + label: "is not", + secondaryLabel: "!=", + value: "color-not-equal", + }, +]; + +export const valueFormatter = (value: any) => { + if (value && value.hex) { + return value.hex.toString(); + } + return ""; +}; diff --git a/src/components/fields/Color/index.tsx b/src/components/fields/Color/index.tsx index cb266301..140593a3 100644 --- a/src/components/fields/Color/index.tsx +++ b/src/components/fields/Color/index.tsx @@ -5,6 +5,7 @@ import { toColor } from "react-color-palette"; import ColorIcon from "@mui/icons-material/Colorize"; import DisplayCell from "./DisplayCell"; +import { filterOperators, valueFormatter } from "./filters"; const EditorCell = lazy( () => import("./EditorCell" /* webpackChunkName: "EditorCell-Color" */) @@ -28,6 +29,10 @@ export const config: IFieldConfig = { disablePadding: true, }), SideDrawerField, + filter: { + operators: filterOperators, + valueFormatter, + }, csvImportParser: (value: string) => { try { const obj = JSON.parse(value); diff --git a/src/components/fields/Json/SideDrawerField.tsx b/src/components/fields/Json/SideDrawerField.tsx index 89ddbc1e..4c0fbc96 100644 --- a/src/components/fields/Json/SideDrawerField.tsx +++ b/src/components/fields/Json/SideDrawerField.tsx @@ -40,13 +40,14 @@ export default function Json({ const [editor, setEditor] = useAtom(jsonEditorAtom, projectScope); const [codeValid, setCodeValid] = useState(true); - const sanitizedValue = + const baseValue = value !== undefined && isValidJson(value) ? value : column.config?.isArray ? [] : {}; - const formattedJson = stringify(sanitizedValue, { space: 2 }); + const formattedJson = stringify(baseValue, { space: 2 }); + const sanitizedValue = JSON.parse(formattedJson); if (disabled) return ( diff --git a/src/components/fields/Rating/DisplayCell.tsx b/src/components/fields/Rating/DisplayCell.tsx index 1e539ad4..b5d28471 100644 --- a/src/components/fields/Rating/DisplayCell.tsx +++ b/src/components/fields/Rating/DisplayCell.tsx @@ -2,24 +2,7 @@ import React, { forwardRef } from "react"; import { IDisplayCellProps } from "@src/components/fields/types"; import MuiRating, { RatingProps as MuiRatingProps } from "@mui/material/Rating"; -import RatingIcon from "@mui/icons-material/Star"; -import RatingOutlineIcon from "@mui/icons-material/StarBorder"; -import { get } from "lodash-es"; - -export const getStateIcon = (config: any) => { - // only use the config to get the custom rating icon if enabled via toggle - if (!get(config, "customIcons.enabled")) { - return ; - } - return get(config, "customIcons.rating") || ; -}; - -export const getStateOutline = (config: any) => { - if (!get(config, "customIcons.enabled")) { - return ; - } - return get(config, "customIcons.rating") || ; -}; +import Icon from "./Icon"; export const Rating = forwardRef(function Rating( { @@ -55,14 +38,15 @@ export const Rating = forwardRef(function Rating( if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) e.stopPropagation(); }} - icon={getStateIcon(column.config)} + icon={} + emptyIcon={} size="small" readOnly={disabled} - emptyIcon={getStateOutline(column.config)} max={max} precision={precision} sx={{ mx: -0.25 }} /> ); }); + export default Rating; diff --git a/src/components/fields/Rating/Icon.tsx b/src/components/fields/Rating/Icon.tsx new file mode 100644 index 00000000..e65b8f3d --- /dev/null +++ b/src/components/fields/Rating/Icon.tsx @@ -0,0 +1,31 @@ +import RatingIcon from "@mui/icons-material/Star"; +import RatingOutlineIcon from "@mui/icons-material/StarBorder"; +import { get } from "lodash-es"; + +export interface IIconProps { + config: any; + isEmpty: boolean; +} + +export default function Icon({ config, isEmpty }: IIconProps) { + if (isEmpty) { + return getStateOutline(config); + } else { + return getStateIcon(config); + } +} + +const getStateIcon = (config: any) => { + // only use the config to get the custom rating icon if enabled via toggle + if (!get(config, "customIcons.enabled")) { + return ; + } + console.log(get(config, "customIcons.rating")); + return get(config, "customIcons.rating") || ; +}; +const getStateOutline = (config: any) => { + if (!get(config, "customIcons.enabled")) { + return ; + } + return get(config, "customIcons.rating") || ; +}; diff --git a/src/components/fields/Rating/Settings.tsx b/src/components/fields/Rating/Settings.tsx index 6134b530..7bf99d5b 100644 --- a/src/components/fields/Rating/Settings.tsx +++ b/src/components/fields/Rating/Settings.tsx @@ -1,11 +1,17 @@ import { ISettingsProps } from "@src/components/fields/types"; -import RatingIcon from "@mui/icons-material/Star"; -import RatingOutlineIcon from "@mui/icons-material/StarBorder" -import { InputLabel, TextField, Grid, FormControlLabel, Checkbox, Stack } from "@mui/material"; +import { + InputLabel, + TextField, + Grid, + FormControlLabel, + Checkbox, + Stack, +} from "@mui/material"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import MuiRating from "@mui/material/Rating"; import { get } from "lodash-es"; +import Icon from "./Icon"; export default function Settings({ onChange, config }: ISettingsProps) { return ( @@ -18,10 +24,13 @@ export default function Settings({ onChange, config }: ISettingsProps) { fullWidth error={false} onChange={(e) => { - let input = parseInt(e.target.value) || 0 - if (input > 20) { input = 20 } + let input = parseInt(e.target.value) || 0; + if (input > 20) { + input = 20; + } onChange("max")(input); }} + inputProps={{ min: 1, max: 20 }} /> @@ -68,28 +77,26 @@ export default function Settings({ onChange, config }: ISettingsProps) { - onChange("customIcons.rating")(e.target.value) - } + onChange={(e) => onChange("customIcons.rating")(e.target.value)} label="Custom icon preview:" className="labelHorizontal" inputProps={{ style: { width: "2ch" } }} /> - e.stopPropagation()} - icon={get(config, "customIcons.rating") || } + icon={} size="small" - emptyIcon={get(config, "customIcons.rating") || } + emptyIcon={} max={get(config, "max")} precision={get(config, "precision")} sx={{ pt: 0.5 }} /> - )} ); -} \ No newline at end of file +} diff --git a/src/components/fields/Rating/SideDrawerField.tsx b/src/components/fields/Rating/SideDrawerField.tsx index 389d3211..ea69325e 100644 --- a/src/components/fields/Rating/SideDrawerField.tsx +++ b/src/components/fields/Rating/SideDrawerField.tsx @@ -3,8 +3,8 @@ import { ISideDrawerFieldProps } from "@src/components/fields/types"; import { Grid } from "@mui/material"; import { Rating as MuiRating } from "@mui/material"; import "@mui/lab"; -import { getStateIcon, getStateOutline } from "./DisplayCell"; import { fieldSx } from "@src/components/SideDrawer/utils"; +import Icon from "./Icon"; export default function Rating({ column, @@ -27,8 +27,8 @@ export default function Rating({ onChange(newValue); onSubmit(); }} - icon={getStateIcon(column.config)} - emptyIcon={getStateOutline(column.config)} + icon={} + emptyIcon={} size="small" max={max} precision={precision} diff --git a/src/constants/externalLinks.ts b/src/constants/externalLinks.ts index bf2f41ab..4625d6ba 100644 --- a/src/constants/externalLinks.ts +++ b/src/constants/externalLinks.ts @@ -15,8 +15,8 @@ export const EXTERNAL_LINKS = { twitter: "https://twitter.com/rowyio", productHunt: "https://www.producthunt.com/products/rowy-2", - rowyRun: meta.repository.url.replace(".git", "Run"), - rowyRunGitHub: meta.repository.url.replace(".git", "Run"), + rowyRun: meta.repository.url.replace("rowy.git", "backend"), + rowyRunGitHub: meta.repository.url.replace("rowy.git", "backend"), // prettier-ignore rowyRunDeploy: `https://deploy.cloud.run/?git_repo=${meta.repository.url.replace(".git", "Run")}.git`, diff --git a/src/hooks/useFirestoreCollectionWithAtom.ts b/src/hooks/useFirestoreCollectionWithAtom.ts index 43f7165a..cf5d648c 100644 --- a/src/hooks/useFirestoreCollectionWithAtom.ts +++ b/src/hooks/useFirestoreCollectionWithAtom.ts @@ -376,12 +376,20 @@ export const tableFiltersToFirestoreFilters = (filters: TableFilter[]) => { } else if (filter.operator === "id-equal") { firestoreFilters.push(where(documentId(), "==", filter.value)); continue; + } else if (filter.operator === "color-equal") { + firestoreFilters.push( + where(filter.key.concat(".hex"), "==", filter.value.hex.toString()) + ); + continue; + } else if (filter.operator === "color-not-equal") { + firestoreFilters.push( + where(filter.key.concat(".hex"), "!=", filter.value.hex.toString()) + ); + continue; } - firestoreFilters.push( where(filter.key, filter.operator as WhereFilterOp, filter.value) ); } - return firestoreFilters; }; diff --git a/src/index.tsx b/src/index.tsx index 5d545f47..f4d7b2c9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,11 +8,11 @@ import reportWebVitals from "./reportWebVitals"; const container = document.getElementById("root")!; const root = createRoot(container); root.render( - - - - - + // + + + + // ); // If you want to start measuring performance in your app, pass a function diff --git a/src/pages/Table/ProvidedTablePage.tsx b/src/pages/Table/ProvidedTablePage.tsx index ab2b7614..8f86be90 100644 --- a/src/pages/Table/ProvidedTablePage.tsx +++ b/src/pages/Table/ProvidedTablePage.tsx @@ -1,16 +1,20 @@ import { lazy, Suspense } from "react"; import { useAtom, Provider } from "jotai"; import { DebugAtoms } from "@src/atoms/utils"; -import { useParams, useOutlet } from "react-router-dom"; +import { useParams, useOutlet, Link } from "react-router-dom"; import { ErrorBoundary } from "react-error-boundary"; import { find, isEmpty } from "lodash-es"; +import useOffline from "@src/hooks/useOffline"; -import ErrorFallback, { - ERROR_TABLE_NOT_FOUND, -} from "@src/components/ErrorFallback"; +import { Typography, Button } from "@mui/material"; + +import ErrorFallback from "@src/components/ErrorFallback"; import TableSourceFirestore from "@src/sources/TableSourceFirestore"; import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton"; import TableSkeleton from "@src/components/Table/TableSkeleton"; +import EmptyState from "@src/components/EmptyState"; +import OfflineIcon from "@mui/icons-material/CloudOff"; +import { Tables as TablesIcon } from "@src/assets/icons"; import { projectScope, @@ -25,6 +29,7 @@ import { tableSettingsAtom, } from "@src/atoms/tableScope"; import { SyncAtomValue } from "@src/atoms/utils"; +import { ROUTES } from "@src/constants/routes"; import useDocumentTitle from "@src/hooks/useDocumentTitle"; // prettier-ignore @@ -41,6 +46,7 @@ export default function ProvidedTablePage() { const [currentUser] = useAtom(currentUserAtom, projectScope); const [projectSettings] = useAtom(projectSettingsAtom, projectScope); const [tables] = useAtom(tablesAtom, projectScope); + const isOffline = useOffline(); const tableSettings = find(tables, ["id", id]); useDocumentTitle(projectId, tableSettings ? tableSettings.name : "Not found"); @@ -54,7 +60,41 @@ export default function ProvidedTablePage() { ); } else { - throw new Error(ERROR_TABLE_NOT_FOUND + ": " + id); + if (isOffline) { + return ( + + ); + } else { + return ( + + + Make sure you have the right ID + + {id} + + + } + /> + ); + } } } diff --git a/src/theme/components.tsx b/src/theme/components.tsx index 2bc4b2e5..374dc25b 100644 --- a/src/theme/components.tsx +++ b/src/theme/components.tsx @@ -1381,11 +1381,12 @@ export const components = (theme: Theme): ThemeOptions => { MuiRating: { styleOverrides: { - iconFilled: { color: theme.palette.text.secondary }, icon: { // https://github.com/mui/material-ui/issues/32557 "& .MuiSvgIcon-root": { pointerEvents: "auto" }, + color: theme.palette.text.secondary, }, + iconEmpty: { opacity: 0.38 }, }, }, diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 5b54b9be..b9753c41 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -4,7 +4,10 @@ import type { DocumentData, DocumentReference, } from "firebase/firestore"; -import { IExtension } from "@src/components/TableModals/ExtensionsModal/utils"; +import { + IExtension, + IRuntimeOptions, +} from "@src/components/TableModals/ExtensionsModal/utils"; import { IWebhook } from "@src/components/TableModals/WebhooksModal/utils"; /** @@ -104,6 +107,7 @@ export type TableSchema = { extensionObjects?: IExtension[]; compiledExtension?: string; webhooks?: IWebhook[]; + runtimeOptions?: IRuntimeOptions; /** @deprecated Migrate to Extensions */ sparks?: string; @@ -146,7 +150,11 @@ export type ColumnConfig = { /** Regex used in CellValidation */ validationRegex: string; /** FieldType to render for Derivative fields */ - renderFieldType: FieldType; + renderFieldType?: FieldType; + /** Used in Derivative fields */ + listenerFields?: string[]; + /** Used in Derivative and Action fields */ + requiredFields?: string[]; /** For sub-table fields */ parentLabel: string[]; @@ -167,7 +175,9 @@ export type TableFilter = { | "date-before-equal" | "date-after-equal" | "time-minute-equal" - | "id-equal"; + | "id-equal" + | "color-equal" + | "color-not-equal"; value: any; };