From 41d8feb84b0243db15d731b95d9b8e42bc7a598e Mon Sep 17 00:00:00 2001 From: Anish Roy <6275anishroy@gmail.com> Date: Thu, 30 Mar 2023 15:03:08 +0530 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20feat(App.tsx):=20add=20route=20?= =?UTF-8?q?for=20ProvidedArraySubTablePage=20at=20/array-sub-table/:docPat?= =?UTF-8?q?h/:subTableKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index ea283a01..d169bb1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import useKeyPressWithAtom from "@src/hooks/useKeyPressWithAtom"; import TableGroupRedirectPage from "./pages/TableGroupRedirectPage"; import SignOutPage from "@src/pages/Auth/SignOutPage"; +import ProvidedArraySubTablePage from "./pages/Table/ProvidedArraySubTablePage"; // prettier-ignore const AuthPage = lazy(() => import("@src/pages/Auth/AuthPage" /* webpackChunkName: "AuthPage" */)); @@ -134,6 +135,27 @@ export default function App() { } /> + + } /> + + + + } + > + + + } + /> + From da0cf161dfb2c9dd63572ee7d5f06d1ee5b36fd0 Mon Sep 17 00:00:00 2001 From: Anish Roy <6275anishroy@gmail.com> Date: Wed, 12 Apr 2023 17:43:07 +0530 Subject: [PATCH 2/8] worked on array subtable --- src/assets/icons/ArraySubTable.tsx | 9 + src/atoms/tableScope/rowActions.test.ts | 36 +- src/atoms/tableScope/rowActions.ts | 47 ++- src/atoms/tableScope/ui.ts | 1 + .../ColumnModals/FieldsDropdown.tsx | 23 +- src/components/SideDrawer/FieldWrapper.tsx | 10 +- src/components/SideDrawer/MemoizedField.tsx | 5 +- src/components/SideDrawer/SideDrawer.tsx | 20 +- .../SideDrawer/SideDrawerFields.tsx | 24 +- .../Table/ContextMenu/MenuContents.tsx | 119 +++--- src/components/Table/EmptyTable.tsx | 81 ++-- .../Table/FinalColumn/FinalColumn.tsx | 119 +++--- src/components/Table/TableBody.tsx | 5 +- .../Table/TableCell/EditorCellController.tsx | 1 + src/components/Table/TableCell/TableCell.tsx | 4 + .../Table/useKeyboardNavigation.tsx | 5 + src/components/Table/useMenuAction.tsx | 17 +- src/components/TableToolbar/AddRow.tsx | 86 +++++ src/components/TableToolbar/TableToolbar.tsx | 55 ++- src/components/fields/Action/index.tsx | 1 + .../fields/ArraySubTable/DisplayCell.tsx | 46 +++ .../fields/ArraySubTable/Settings.tsx | 32 ++ .../fields/ArraySubTable/SideDrawerField.tsx | 56 +++ src/components/fields/ArraySubTable/index.tsx | 36 ++ src/components/fields/ArraySubTable/utils.ts | 34 ++ src/components/fields/CreatedAt/index.tsx | 1 + src/components/fields/CreatedBy/index.tsx | 1 + src/components/fields/Derivative/index.tsx | 1 + src/components/fields/File/EditorCell.tsx | 9 +- .../fields/File/SideDrawerField.tsx | 8 +- src/components/fields/File/useFileUpload.ts | 19 +- src/components/fields/Image/EditorCell.tsx | 18 +- .../fields/Image/SideDrawerField.tsx | 14 +- src/components/fields/SubTable/index.tsx | 1 + src/components/fields/UpdatedAt/index.tsx | 1 + src/components/fields/UpdatedBy/index.tsx | 1 + src/components/fields/index.ts | 2 + src/components/fields/types.ts | 5 +- src/constants/fields.ts | 1 + src/constants/routes.tsx | 2 + .../useFirestoreDocAsCollectionWithAtom.ts | 357 ++++++++++++++++++ src/pages/Table/ProvidedArraySubTablePage.tsx | 156 ++++++++ src/pages/Table/TablePage.tsx | 9 +- .../ArraySubTableSourceFirestore.tsx | 143 +++++++ src/types/table.d.ts | 29 +- src/utils/table.ts | 1 + 46 files changed, 1450 insertions(+), 201 deletions(-) create mode 100644 src/assets/icons/ArraySubTable.tsx create mode 100644 src/components/fields/ArraySubTable/DisplayCell.tsx create mode 100644 src/components/fields/ArraySubTable/Settings.tsx create mode 100644 src/components/fields/ArraySubTable/SideDrawerField.tsx create mode 100644 src/components/fields/ArraySubTable/index.tsx create mode 100644 src/components/fields/ArraySubTable/utils.ts create mode 100644 src/hooks/useFirestoreDocAsCollectionWithAtom.ts create mode 100644 src/pages/Table/ProvidedArraySubTablePage.tsx create mode 100644 src/sources/TableSourceFirestore/ArraySubTableSourceFirestore.tsx diff --git a/src/assets/icons/ArraySubTable.tsx b/src/assets/icons/ArraySubTable.tsx new file mode 100644 index 00000000..d7f5a2ad --- /dev/null +++ b/src/assets/icons/ArraySubTable.tsx @@ -0,0 +1,9 @@ +import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; + +export function ArraySubTable(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/atoms/tableScope/rowActions.test.ts b/src/atoms/tableScope/rowActions.test.ts index fe35d654..5ff521a7 100644 --- a/src/atoms/tableScope/rowActions.test.ts +++ b/src/atoms/tableScope/rowActions.test.ts @@ -494,7 +494,11 @@ describe("deleteRow", () => { } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); expect(deleteRow).toBeDefined(); - await act(() => deleteRow(TEST_COLLECTION + "/row2")); + await act(() => + deleteRow({ + path: TEST_COLLECTION + "/row2", + }) + ); const { result: { current: tableRows }, @@ -510,7 +514,11 @@ describe("deleteRow", () => { } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); expect(deleteRow).toBeDefined(); - await act(() => deleteRow(TEST_COLLECTION + "/rowLocal2")); + await act(() => + deleteRow({ + path: TEST_COLLECTION + "/rowLocal2", + }) + ); const { result: { current: tableRows }, @@ -527,9 +535,9 @@ describe("deleteRow", () => { expect(deleteRow).toBeDefined(); await act(() => - deleteRow( - ["row1", "row2", "row8"].map((id) => TEST_COLLECTION + "/" + id) - ) + deleteRow({ + path: ["row1", "row2", "row8"].map((id) => TEST_COLLECTION + "/" + id), + }) ); const { @@ -548,7 +556,11 @@ describe("deleteRow", () => { } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); expect(deleteRow).toBeDefined(); - await act(() => deleteRow(generatedRows.map((row) => row._rowy_ref.path))); + await act(() => + deleteRow({ + path: generatedRows.map((row) => row._rowy_ref.path), + }) + ); const { result: { current: tableRows }, @@ -563,7 +575,11 @@ describe("deleteRow", () => { } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); expect(deleteRow).toBeDefined(); - await act(() => deleteRow("nonExistent")); + await act(() => + deleteRow({ + path: "nonExistent", + }) + ); const { result: { current: tableRows }, @@ -578,7 +594,11 @@ describe("deleteRow", () => { } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); expect(deleteRow).toBeDefined(); - await act(() => deleteRow("nonExistent")); + await act(() => + deleteRow({ + path: "nonExistent", + }) + ); const { result: { current: tableRows }, diff --git a/src/atoms/tableScope/rowActions.ts b/src/atoms/tableScope/rowActions.ts index 75b496f5..8a885d80 100644 --- a/src/atoms/tableScope/rowActions.ts +++ b/src/atoms/tableScope/rowActions.ts @@ -22,7 +22,11 @@ import { _bulkWriteDbAtom, } from "./table"; -import { TableRow, BulkWriteFunction } from "@src/types/table"; +import { + TableRow, + BulkWriteFunction, + ArrayTableRowData, +} from "@src/types/table"; import { rowyUser, generateId, @@ -211,7 +215,17 @@ export const addRowAtom = atom( */ export const deleteRowAtom = atom( null, - async (get, set, path: string | string[]) => { + async ( + get, + set, + { + path, + options, + }: { + path: string | string[]; + options?: ArrayTableRowData; + } + ) => { const deleteRowDb = get(_deleteRowDbAtom); if (!deleteRowDb) throw new Error("Cannot write to database"); @@ -223,9 +237,9 @@ export const deleteRowAtom = atom( find(tableRowsLocal, ["_rowy_ref.path", path]) ); if (isLocalRow) set(tableRowsLocalAtom, { type: "delete", path }); - // Always delete from db in case it exists - await deleteRowDb(path); + // *options* are passed in case of array table to target specific row + await deleteRowDb(path, options); if (auditChange) auditChange("DELETE_ROW", path); }; @@ -312,6 +326,8 @@ export interface IUpdateFieldOptions { useArrayUnion?: boolean; /** Optionally, uses firestore's arrayRemove with the given value. Removes given value items from the existing array */ useArrayRemove?: boolean; + /** Optionally, used to locate the row in ArraySubTable. */ + arrayTableData?: ArrayTableRowData; } /** * Set function updates or deletes a field in a row. @@ -339,6 +355,7 @@ export const updateFieldAtom = atom( disableCheckEquality, useArrayUnion, useArrayRemove, + arrayTableData, }: IUpdateFieldOptions ) => { const updateRowDb = get(_updateRowDbAtom); @@ -387,7 +404,12 @@ export const updateFieldAtom = atom( ...(row[fieldName] ?? []), ...localUpdate[fieldName], ]; - dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]); + // if we are updating a row of ArraySubTable + if (arrayTableData?.index !== undefined) { + dbUpdate[fieldName] = localUpdate[fieldName]; + } else { + dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]); + } } //apply arrayRemove @@ -400,8 +422,15 @@ export const updateFieldAtom = atom( row[fieldName] ?? [], (el) => !find(localUpdate[fieldName], el) ); - dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]); + + // if we are updating a row of ArraySubTable + if (arrayTableData?.index !== undefined) { + dbUpdate[fieldName] = localUpdate[fieldName]; + } else { + dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]); + } } + // need to pass the index of the row to updateRowDb // Check for required fields const newRowValues = updateRowData(cloneDeep(row), dbUpdate); @@ -431,7 +460,8 @@ export const updateFieldAtom = atom( await updateRowDb( row._rowy_ref.path, omitRowyFields(newRowValues), - deleteField ? [fieldName] : [] + deleteField ? [fieldName] : [], + arrayTableData ); } } @@ -440,7 +470,8 @@ export const updateFieldAtom = atom( await updateRowDb( row._rowy_ref.path, omitRowyFields(dbUpdate), - deleteField ? [fieldName] : [] + deleteField ? [fieldName] : [], + arrayTableData ); } diff --git a/src/atoms/tableScope/ui.ts b/src/atoms/tableScope/ui.ts index 7182a41d..b170d250 100644 --- a/src/atoms/tableScope/ui.ts +++ b/src/atoms/tableScope/ui.ts @@ -134,6 +134,7 @@ export type SelectedCell = { path: string | "_rowy_header"; columnKey: string | "_rowy_row_actions"; focusInside: boolean; + arrayIndex?: number; // for array sub table }; /** Store selected cell in table. Used in side drawer and context menu */ export const selectedCellAtom = atom(null); diff --git a/src/components/ColumnModals/FieldsDropdown.tsx b/src/components/ColumnModals/FieldsDropdown.tsx index 529c3b84..2b2497e8 100644 --- a/src/components/ColumnModals/FieldsDropdown.tsx +++ b/src/components/ColumnModals/FieldsDropdown.tsx @@ -11,6 +11,7 @@ import { projectSettingsAtom, rowyRunModalAtom, } from "@src/atoms/projectScope"; +import { tableScope, tableSettingsAtom } from "@src/atoms/tableScope"; export interface IFieldsDropdownProps { value: FieldType | ""; @@ -35,17 +36,22 @@ export default function FieldsDropdown({ }: IFieldsDropdownProps) { const [projectSettings] = useAtom(projectSettingsAtom, projectScope); const openRowyRunModal = useSetAtom(rowyRunModalAtom, projectScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const fieldTypesToDisplay = optionsProp ? FIELDS.filter((fieldConfig) => optionsProp.indexOf(fieldConfig.type) > -1) : FIELDS; const options = fieldTypesToDisplay.map((fieldConfig) => { const requireCloudFunctionSetup = fieldConfig.requireCloudFunction && !projectSettings.rowyRunUrl; + const requireCollectionTable = + tableSettings.isNotACollection === true && + fieldConfig.requireCollectionTable === true; return { label: fieldConfig.name, value: fieldConfig.type, - disabled: requireCloudFunctionSetup, + disabled: requireCloudFunctionSetup || requireCollectionTable, requireCloudFunctionSetup, + requireCollectionTable, }; }); @@ -82,7 +88,18 @@ export default function FieldsDropdown({ {getFieldProp("icon", option.value as FieldType)} {option.label} - {option.requireCloudFunctionSetup && ( + {option.requireCollectionTable ? ( + + {" "} + Unavailable + + ) : option.requireCloudFunctionSetup ? ( - )} + ) : null} )} label={label || "Field type"} diff --git a/src/components/SideDrawer/FieldWrapper.tsx b/src/components/SideDrawer/FieldWrapper.tsx index c6a352cd..eb76afa8 100644 --- a/src/components/SideDrawer/FieldWrapper.tsx +++ b/src/components/SideDrawer/FieldWrapper.tsx @@ -35,6 +35,7 @@ export interface IFieldWrapperProps { fieldName?: string; label?: React.ReactNode; debugText?: React.ReactNode; + debugValue?: React.ReactNode; disabled?: boolean; hidden?: boolean; index?: number; @@ -46,6 +47,7 @@ export default function FieldWrapper({ fieldName, label, debugText, + debugValue, disabled, hidden, index, @@ -100,7 +102,7 @@ export default function FieldWrapper({ }> {children ?? - (!debugText && ( + (!debugValue && ( - {debugText && ( + {debugValue && ( { - copyToClipboard(debugText as string); + copyToClipboard(debugValue as string); enqueueSnackbar("Copied!"); }} > @@ -139,7 +141,7 @@ export default function FieldWrapper({ void; onSubmit: (fieldName: string, value: any) => void; @@ -25,6 +26,7 @@ export const MemoizedField = memo( hidden, value, _rowy_ref, + _rowy_arrayTableData, isDirty, onDirty, onSubmit, @@ -78,6 +80,7 @@ export const MemoizedField = memo( }, onSubmit: handleSubmit, disabled, + _rowy_arrayTableData, })} ); diff --git a/src/components/SideDrawer/SideDrawer.tsx b/src/components/SideDrawer/SideDrawer.tsx index ef676bd1..faeb15ad 100644 --- a/src/components/SideDrawer/SideDrawer.tsx +++ b/src/components/SideDrawer/SideDrawer.tsx @@ -30,11 +30,21 @@ export default function SideDrawer() { const [cell, setCell] = useAtom(selectedCellAtom, tableScope); const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope); - const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]); - const selectedCellRowIndex = findIndex(tableRows, [ - "_rowy_ref.path", - cell?.path, - ]); + const selectedRow = find( + tableRows, + cell?.arrayIndex === undefined + ? ["_rowy_ref.path", cell?.path] + : // if the table is an array table, we need to use the array index to find the row + ["_rowy_arrayTableData.index", cell?.arrayIndex] + ); + + const selectedCellRowIndex = findIndex( + tableRows, + cell?.arrayIndex === undefined + ? ["_rowy_ref.path", cell?.path] + : // if the table is an array table, we need to use the array index to find the row + ["_rowy_arrayTableData.index", cell?.arrayIndex] + ); const handleNavigate = (direction: "up" | "down") => () => { if (!tableRows || !cell) return; diff --git a/src/components/SideDrawer/SideDrawerFields.tsx b/src/components/SideDrawer/SideDrawerFields.tsx index 7b6a6578..e2abdfa4 100644 --- a/src/components/SideDrawer/SideDrawerFields.tsx +++ b/src/components/SideDrawer/SideDrawerFields.tsx @@ -66,7 +66,16 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) { setSaveState("saving"); try { - await updateField({ path: selectedCell!.path, fieldName, value }); + await updateField({ + path: selectedCell!.path, + fieldName, + value, + deleteField: undefined, + arrayTableData: { + index: selectedCell.arrayIndex ?? 0, + }, + }); + setSaveState("saved"); } catch (e) { enqueueSnackbar((e as Error).message, { variant: "error" }); @@ -121,6 +130,7 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) { onDirty={onDirty} onSubmit={onSubmit} isDirty={dirtyField === field.key} + _rowy_arrayTableData={row._rowy_arrayTableData} /> ))} @@ -128,7 +138,17 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) { type="debug" fieldName="_rowy_ref.path" label="Document path" - debugText={row._rowy_ref.path ?? row._rowy_ref.id ?? "No ref"} + debugText={ + row._rowy_arrayTableData + ? row._rowy_ref.path + + " → " + + row._rowy_arrayTableData.parentField + + "[" + + row._rowy_arrayTableData.index + + "]" + : row._rowy_ref.path + } + debugValue={row._rowy_ref.path ?? row._rowy_ref.id ?? "No ref"} /> {userDocHiddenFields.length > 0 && ( diff --git a/src/components/Table/ContextMenu/MenuContents.tsx b/src/components/Table/ContextMenu/MenuContents.tsx index 88a973c2..5154ba8b 100644 --- a/src/components/Table/ContextMenu/MenuContents.tsx +++ b/src/components/Table/ContextMenu/MenuContents.tsx @@ -34,6 +34,7 @@ import { deleteRowAtom, updateFieldAtom, tableFiltersPopoverAtom, + _updateRowDbAtom, } from "@src/atoms/tableScope"; import { FieldType } from "@src/constants/fields"; @@ -54,6 +55,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { const addRow = useSetAtom(addRowAtom, tableScope); const deleteRow = useSetAtom(deleteRowAtom, tableScope); const updateField = useSetAtom(updateFieldAtom, tableScope); + const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope); const openTableFiltersPopover = useSetAtom( tableFiltersPopoverAtom, tableScope @@ -62,19 +64,83 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { if (!tableSchema.columns || !selectedCell) return null; const selectedColumn = tableSchema.columns[selectedCell.columnKey]; - const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]); + const row = find( + tableRows, + selectedCell?.arrayIndex === undefined + ? ["_rowy_ref.path", selectedCell.path] + : // if the table is an array table, we need to use the array index to find the row + ["_rowy_arrayTableData.index", selectedCell.arrayIndex] + ); if (!row) return null; const actionGroups: IContextMenuItem[][] = []; const handleDuplicate = () => { - addRow({ - row, - setId: addRowIdType === "custom" ? "decrement" : addRowIdType, - }); + const _duplicate = () => { + if (row._rowy_arrayTableData !== undefined) { + if (!updateRowDb) return; + + return updateRowDb("", {}, undefined, { + index: row._rowy_arrayTableData.index, + operation: { + addRow: "bottom", + base: row, + }, + }); + } + return addRow({ + row: row, + setId: addRowIdType === "custom" ? "decrement" : addRowIdType, + }); + }; + + if (altPress || row._rowy_arrayTableData !== undefined) { + _duplicate(); + } else { + confirm({ + title: "Duplicate row?", + body: ( + <> + Row path: +
+ + {row._rowy_ref.path} + + + ), + confirm: "Duplicate", + handleConfirm: _duplicate, + }); + } + }; + const handleDelete = () => { + const _delete = () => + deleteRow({ + path: row._rowy_ref.path, + options: row._rowy_arrayTableData, + }); + + if (altPress || row._rowy_arrayTableData !== undefined) { + _delete(); + } else { + confirm({ + title: "Delete row?", + body: ( + <> + Row path: +
+ + {row._rowy_ref.path} + + + ), + confirm: "Delete", + confirmColor: "error", + handleConfirm: _delete, + }); + } }; - const handleDelete = () => deleteRow(row._rowy_ref.path); const rowActions: IContextMenuItem[] = [ { label: "Copy ID", @@ -112,51 +178,14 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { disabled: tableSettings.tableType === "collectionGroup" || (!userRoles.includes("ADMIN") && tableSettings.readOnly), - onClick: altPress - ? handleDuplicate - : () => { - confirm({ - title: "Duplicate row?", - body: ( - <> - Row path: -
- - {row._rowy_ref.path} - - - ), - confirm: "Duplicate", - handleConfirm: handleDuplicate, - }); - onClose(); - }, + onClick: handleDuplicate, }, { label: altPress ? "Delete" : "Delete…", color: "error", icon: , disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly, - onClick: altPress - ? handleDelete - : () => { - confirm({ - title: "Delete row?", - body: ( - <> - Row path: -
- - {row._rowy_ref.path} - - - ), - confirm: "Delete", - confirmColor: "error", - handleConfirm: handleDelete, - }); - onClose(); - }, + onClick: handleDelete, }, ]; diff --git a/src/components/Table/EmptyTable.tsx b/src/components/Table/EmptyTable.tsx index f07839b3..1f14e2db 100644 --- a/src/components/Table/EmptyTable.tsx +++ b/src/components/Table/EmptyTable.tsx @@ -34,7 +34,7 @@ export default function EmptyTable() { : false; let contents = <>; - if (hasData) { + if (!tableSettings.isNotACollection && hasData) { contents = ( <>
@@ -72,47 +72,56 @@ export default function EmptyTable() { Get started - There is no data in the Firestore collection: + {tableSettings.isNotACollection === true + ? "There is no data in this Array Sub Table:" + : "There is no data in the Firestore collection:"}
- {tableSettings.collection} + + {tableSettings.collection} + {tableSettings.subTableKey?.length && + `.${tableSettings.subTableKey}`} +
- - - - You can import data from an external source: - + {!tableSettings.isNotACollection && ( + <> + + + You can import data from an external source: + - ( - - )} - PopoverProps={{ - anchorOrigin: { - vertical: "bottom", - horizontal: "center", - }, - transformOrigin: { - vertical: "top", - horizontal: "center", - }, - }} - /> - + ( + + )} + PopoverProps={{ + anchorOrigin: { + vertical: "bottom", + horizontal: "center", + }, + transformOrigin: { + vertical: "top", + horizontal: "center", + }, + }} + /> + - - - or - - + + + or + + + + )} diff --git a/src/components/Table/FinalColumn/FinalColumn.tsx b/src/components/Table/FinalColumn/FinalColumn.tsx index 28b584e6..041d3f0f 100644 --- a/src/components/Table/FinalColumn/FinalColumn.tsx +++ b/src/components/Table/FinalColumn/FinalColumn.tsx @@ -20,8 +20,8 @@ import { addRowAtom, deleteRowAtom, contextMenuTargetAtom, + _updateRowDbAtom, } from "@src/atoms/tableScope"; - export const FinalColumn = memo(function FinalColumn({ row, focusInsideCell, @@ -31,17 +31,77 @@ export const FinalColumn = memo(function FinalColumn({ const confirm = useSetAtom(confirmDialogAtom, projectScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope); + const addRow = useSetAtom(addRowAtom, tableScope); const deleteRow = useSetAtom(deleteRowAtom, tableScope); const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope); const [altPress] = useAtom(altPressAtom, projectScope); - const handleDelete = () => deleteRow(row.original._rowy_ref.path); + + const handleDelete = () => { + const _delete = () => + deleteRow({ + path: row.original._rowy_ref.path, + options: row.original._rowy_arrayTableData, + }); + if (altPress || row.original._rowy_arrayTableData !== undefined) { + _delete(); + } else { + confirm({ + title: "Delete row?", + body: ( + <> + Row path: +
+ + {row.original._rowy_ref.path} + + + ), + confirm: "Delete", + confirmColor: "error", + handleConfirm: _delete, + }); + } + }; + const handleDuplicate = () => { - addRow({ - row: row.original, - setId: addRowIdType === "custom" ? "decrement" : addRowIdType, - }); + const _duplicate = () => { + if (row.original._rowy_arrayTableData !== undefined) { + if (!updateRowDb) return; + + return updateRowDb("", {}, undefined, { + index: row.original._rowy_arrayTableData.index, + operation: { + addRow: "bottom", + base: row.original, + }, + }); + } + return addRow({ + row: row.original, + setId: addRowIdType === "custom" ? "decrement" : addRowIdType, + }); + }; + if (altPress || row.original._rowy_arrayTableData !== undefined) { + _duplicate(); + } else { + confirm({ + title: "Duplicate row?", + body: ( + <> + Row path: +
+ + {row.original._rowy_ref.path} + + + ), + confirm: "Duplicate", + handleConfirm: _duplicate, + }); + } }; if (!userRoles.includes("ADMIN") && tableSettings.readOnly === true) @@ -73,28 +133,7 @@ export const FinalColumn = memo(function FinalColumn({ size="small" color="inherit" disabled={tableSettings.tableType === "collectionGroup"} - onClick={ - altPress - ? handleDuplicate - : () => { - confirm({ - title: "Duplicate row?", - body: ( - <> - Row path: -
- - {row.original._rowy_ref.path} - - - ), - confirm: "Duplicate", - handleConfirm: handleDuplicate, - }); - } - } + onClick={handleDuplicate} className="row-hover-iconButton" tabIndex={focusInsideCell ? 0 : -1} > @@ -106,29 +145,7 @@ export const FinalColumn = memo(function FinalColumn({ { - confirm({ - title: "Delete row?", - body: ( - <> - Row path: -
- - {row.original._rowy_ref.path} - - - ), - confirm: "Delete", - confirmColor: "error", - handleConfirm: handleDelete, - }); - } - } + onClick={handleDelete} className="row-hover-iconButton" tabIndex={focusInsideCell ? 0 : -1} sx={{ diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 8dd53d73..edaed491 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -102,7 +102,10 @@ export const TableBody = memo(function TableBody({ const isSelectedCell = selectedCell?.path === row.original._rowy_ref.path && - selectedCell?.columnKey === cell.column.id; + selectedCell?.columnKey === cell.column.id && + // if the table is an array sub table, we need to check the array index as well + selectedCell?.arrayIndex === + row.original._rowy_arrayTableData?.index; const fieldTypeGroup = getFieldProp( "group", diff --git a/src/components/Table/TableCell/EditorCellController.tsx b/src/components/Table/TableCell/EditorCellController.tsx index c80380d4..cacc8946 100644 --- a/src/components/Table/TableCell/EditorCellController.tsx +++ b/src/components/Table/TableCell/EditorCellController.tsx @@ -66,6 +66,7 @@ export default function EditorCellController({ fieldName: props.column.fieldName, value: localValueRef.current, deleteField: localValueRef.current === undefined, + arrayTableData: props.row?._rowy_arrayTableData, }); } catch (e) { enqueueSnackbar((e as Error).message, { variant: "error" }); diff --git a/src/components/Table/TableCell/TableCell.tsx b/src/components/Table/TableCell/TableCell.tsx index 4ea51433..5c664b35 100644 --- a/src/components/Table/TableCell/TableCell.tsx +++ b/src/components/Table/TableCell/TableCell.tsx @@ -123,6 +123,7 @@ export const TableCell = memo(function TableCell({ focusInsideCell, setFocusInsideCell: (focusInside: boolean) => setSelectedCell({ + arrayIndex: row.original._rowy_arrayTableData?.index, path: row.original._rowy_ref.path, columnKey: cell.column.id, focusInside, @@ -166,6 +167,7 @@ export const TableCell = memo(function TableCell({ }} onClick={(e) => { setSelectedCell({ + arrayIndex: row.original._rowy_arrayTableData?.index, path: row.original._rowy_ref.path, columnKey: cell.column.id, focusInside: false, @@ -174,6 +176,7 @@ export const TableCell = memo(function TableCell({ }} onDoubleClick={(e) => { setSelectedCell({ + arrayIndex: row.original._rowy_arrayTableData?.index, path: row.original._rowy_ref.path, columnKey: cell.column.id, focusInside: true, @@ -183,6 +186,7 @@ export const TableCell = memo(function TableCell({ onContextMenu={(e) => { e.preventDefault(); setSelectedCell({ + arrayIndex: row.original._rowy_arrayTableData?.index, path: row.original._rowy_ref.path, columnKey: cell.column.id, focusInside: false, diff --git a/src/components/Table/useKeyboardNavigation.tsx b/src/components/Table/useKeyboardNavigation.tsx index 3353d23f..81141a77 100644 --- a/src/components/Table/useKeyboardNavigation.tsx +++ b/src/components/Table/useKeyboardNavigation.tsx @@ -128,6 +128,11 @@ export function useKeyboardNavigation({ ? tableRows[newRowIndex]._rowy_ref.path : "_rowy_header", columnKey: leafColumns[newColIndex].id! || leafColumns[0].id!, + arrayIndex: + newRowIndex > -1 + ? tableRows[newRowIndex]._rowy_arrayTableData?.index + : undefined, + // When selected cell changes, exit current cell focusInside: false, }; diff --git a/src/components/Table/useMenuAction.tsx b/src/components/Table/useMenuAction.tsx index 516124d3..49cdc2d8 100644 --- a/src/components/Table/useMenuAction.tsx +++ b/src/components/Table/useMenuAction.tsx @@ -71,6 +71,9 @@ export function useMenuAction( fieldName: selectedCol.fieldName, value: undefined, deleteField: true, + arrayTableData: { + index: selectedCell.arrayIndex ?? 0, + }, }); } catch (error) { enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" }); @@ -115,6 +118,9 @@ export function useMenuAction( path: selectedCell.path, fieldName: selectedCol.fieldName, value: parsed, + arrayTableData: { + index: selectedCell.arrayIndex ?? 0, + }, }); } catch (error) { enqueueSnackbar( @@ -130,7 +136,14 @@ export function useMenuAction( const selectedCol = tableSchema.columns?.[selectedCell.columnKey]; if (!selectedCol) return setCellValue(""); setSelectedCol(selectedCol); - const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]); + + const selectedRow = find( + tableRows, + selectedCell.arrayIndex === undefined + ? ["_rowy_ref.path", selectedCell.path] + : // if the table is an array table, we need to use the array index to find the row + ["_rowy_arrayTableData.index", selectedCell.arrayIndex] + ); setCellValue(get(selectedRow, selectedCol.fieldName)); }, [selectedCell, tableSchema, tableRows]); @@ -149,7 +162,7 @@ export function useMenuAction( } }; }, - [selectedCol] + [enqueueSnackbar, selectedCol?.type] ); return { diff --git a/src/components/TableToolbar/AddRow.tsx b/src/components/TableToolbar/AddRow.tsx index c6372865..13e24cd5 100644 --- a/src/components/TableToolbar/AddRow.tsx +++ b/src/components/TableToolbar/AddRow.tsx @@ -27,6 +27,7 @@ import { tableFiltersAtom, tableSortsAtom, addRowAtom, + _updateRowDbAtom, } from "@src/atoms/tableScope"; export default function AddRow() { @@ -207,3 +208,88 @@ export default function AddRow() { ); } + +export function AddRowArraySubTable() { + const [updateRowDb] = useAtom(_updateRowDbAtom, tableScope); + const [open, setOpen] = useState(false); + + const anchorEl = useRef(null); + const [addRowAt, setAddNewRowAt] = useState<"top" | "bottom">("bottom"); + if (!updateRowDb) return null; + + const handleClick = () => { + updateRowDb("", {}, undefined, { + index: 0, + operation: { + addRow: addRowAt, + }, + }); + }; + return ( + <> + + + + + + + + + ); +} diff --git a/src/components/TableToolbar/TableToolbar.tsx b/src/components/TableToolbar/TableToolbar.tsx index 7af0f6b9..d7d21fbe 100644 --- a/src/components/TableToolbar/TableToolbar.tsx +++ b/src/components/TableToolbar/TableToolbar.tsx @@ -1,17 +1,19 @@ import { lazy, Suspense } from "react"; import { useAtom, useSetAtom } from "jotai"; -import { Stack } from "@mui/material"; +import { Button, Stack } from "@mui/material"; import WebhookIcon from "@mui/icons-material/Webhook"; import { Export as ExportIcon, Extension as ExtensionIcon, CloudLogs as CloudLogsIcon, + Import as ImportIcon, } from "@src/assets/icons"; + import TableToolbarButton from "./TableToolbarButton"; import { ButtonSkeleton } from "./TableToolbarSkeleton"; -import AddRow from "./AddRow"; +import AddRow, { AddRowArraySubTable } from "./AddRow"; import LoadedRowsStatus from "./LoadedRowsStatus"; import TableSettings from "./TableSettings"; import HiddenFields from "./HiddenFields"; @@ -32,6 +34,8 @@ import { tableModalAtom, } from "@src/atoms/tableScope"; import { FieldType } from "@src/constants/fields"; +import { TableToolsType } from "@src/types/table"; +import FilterIcon from "@mui/icons-material/FilterList"; // prettier-ignore const Filters = lazy(() => import("./Filters" /* webpackChunkName: "Filters" */)); @@ -43,7 +47,11 @@ const ReExecute = lazy(() => import("./ReExecute" /* webpackChunkName: "ReExecut export const TABLE_TOOLBAR_HEIGHT = 44; -export default function TableToolbar() { +export default function TableToolbar({ + disabledTools, +}: { + disabledTools?: TableToolsType[]; +}) { const [projectSettings] = useAtom(projectSettingsAtom, projectScope); const [userRoles] = useAtom(userRolesAtom, projectScope); const [compatibleRowyRunVersion] = useAtom( @@ -54,7 +62,6 @@ export default function TableToolbar() { const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const [tableSchema] = useAtom(tableSchemaAtom, tableScope); const openTableModal = useSetAtom(tableModalAtom, tableScope); - const hasDerivatives = Object.values(tableSchema.columns ?? {}).filter( (column) => column.type === FieldType.derivative @@ -64,6 +71,7 @@ export default function TableToolbar() { tableSchema.compiledExtension && tableSchema.compiledExtension.replace(/\W/g, "")?.length > 0; + disabledTools = disabledTools ?? []; return ( - + {tableSettings.isNotACollection ? : }
{/* Spacer */} - }> - - + {tableSettings.isNotACollection ? ( + + ) : ( + }> + + + )}
{/* Spacer */}
{/* Spacer */} - {tableSettings.tableType !== "collectionGroup" && ( - }> - - + {disabledTools.includes("import") ? ( + } + disabled={true} + /> + ) : ( + tableSettings.tableType !== "collectionGroup" && ( + }> + + + ) )} }> openTableModal("export")} icon={} + disabled={disabledTools.includes("export")} /> {userRoles.includes("ADMIN") && ( @@ -123,6 +151,7 @@ export default function TableToolbar() { } }} icon={} + disabled={disabledTools.includes("webhooks")} /> } + disabled={disabledTools.includes("extensions")} /> {(hasDerivatives || hasExtensions) && ( }> diff --git a/src/components/fields/Action/index.tsx b/src/components/fields/Action/index.tsx index 7ab899e2..53b3629d 100644 --- a/src/components/fields/Action/index.tsx +++ b/src/components/fields/Action/index.tsx @@ -31,6 +31,7 @@ export const config: IFieldConfig = { settings: Settings, requireConfiguration: true, requireCloudFunction: true, + requireCollectionTable: true, sortKey: "status", }; export default config; diff --git a/src/components/fields/ArraySubTable/DisplayCell.tsx b/src/components/fields/ArraySubTable/DisplayCell.tsx new file mode 100644 index 00000000..3796d553 --- /dev/null +++ b/src/components/fields/ArraySubTable/DisplayCell.tsx @@ -0,0 +1,46 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; +import { Link } from "react-router-dom"; + +import { Stack, IconButton } from "@mui/material"; +import OpenIcon from "@mui/icons-material/OpenInBrowser"; + +import { useSubTableData } from "./utils"; + +export default function ArraySubTable({ + column, + row, + _rowy_ref, + tabIndex, +}: IDisplayCellProps) { + const { documentCount, label, subTablePath } = useSubTableData( + column as any, + row, + _rowy_ref + ); + + if (!_rowy_ref) return null; + + return ( + +
+ {documentCount} {column.name as string}: {label} +
+ + + + +
+ ); +} diff --git a/src/components/fields/ArraySubTable/Settings.tsx b/src/components/fields/ArraySubTable/Settings.tsx new file mode 100644 index 00000000..d586d46c --- /dev/null +++ b/src/components/fields/ArraySubTable/Settings.tsx @@ -0,0 +1,32 @@ +import { useAtom } from "jotai"; +import { ISettingsProps } from "@src/components/fields/types"; + +import MultiSelect from "@rowy/multiselect"; +import { FieldType } from "@src/constants/fields"; + +import { tableScope, tableColumnsOrderedAtom } from "@src/atoms/tableScope"; + +const Settings = ({ config, onChange }: ISettingsProps) => { + const [tableOrderedColumns] = useAtom(tableColumnsOrderedAtom, tableScope); + + const columnOptions = tableOrderedColumns + .filter((column) => + [ + FieldType.shortText, + FieldType.singleSelect, + FieldType.email, + FieldType.phone, + ].includes(column.type) + ) + .map((c) => ({ label: c.name, value: c.key })); + + return ( + + ); +}; +export default Settings; diff --git a/src/components/fields/ArraySubTable/SideDrawerField.tsx b/src/components/fields/ArraySubTable/SideDrawerField.tsx new file mode 100644 index 00000000..0f03a45b --- /dev/null +++ b/src/components/fields/ArraySubTable/SideDrawerField.tsx @@ -0,0 +1,56 @@ +import { useMemo } from "react"; +import { useAtom } from "jotai"; +import { selectAtom } from "jotai/utils"; +import { find, isEqual } from "lodash-es"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; +import { Link } from "react-router-dom"; + +import { Box, Stack, IconButton } from "@mui/material"; +import OpenIcon from "@mui/icons-material/OpenInBrowser"; + +import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; +import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils"; +import { useSubTableData } from "./utils"; + +export default function ArraySubTable({ + column, + _rowy_ref, +}: ISideDrawerFieldProps) { + const [row] = useAtom( + useMemo( + () => + selectAtom( + tableRowsAtom, + (tableRows) => find(tableRows, ["_rowy_ref.path", _rowy_ref.path]), + isEqual + ), + [_rowy_ref.path] + ), + tableScope + ); + + const { documentCount, label, subTablePath } = useSubTableData( + column as any, + row as any, + _rowy_ref + ); + + return ( + + + {documentCount} {column.name as string}: {label} + + + + + + + ); +} diff --git a/src/components/fields/ArraySubTable/index.tsx b/src/components/fields/ArraySubTable/index.tsx new file mode 100644 index 00000000..9e062b65 --- /dev/null +++ b/src/components/fields/ArraySubTable/index.tsx @@ -0,0 +1,36 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; + +import { ArraySubTable as ArraySubTableIcon } from "@src/assets/icons/ArraySubTable"; +import DisplayCell from "./DisplayCell"; + +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-ArraySubTable" */ + ) +); +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-ArraySubtable" */) +); +export const config: IFieldConfig = { + type: FieldType.arraySubTable, + name: "Array-Sub-Table", + group: "Connection", + dataType: "undefined", + initialValue: null, + icon: , + settings: Settings, + 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, null, "focus", { + usesRowData: true, + disablePadding: true, + }), + SideDrawerField, + initializable: false, + requireConfiguration: true, + requireCollectionTable: true, +}; +export default config; diff --git a/src/components/fields/ArraySubTable/utils.ts b/src/components/fields/ArraySubTable/utils.ts new file mode 100644 index 00000000..c00f7d7c --- /dev/null +++ b/src/components/fields/ArraySubTable/utils.ts @@ -0,0 +1,34 @@ +import { useLocation } from "react-router-dom"; + +import { ROUTES } from "@src/constants/routes"; +import { ColumnConfig, TableRow, TableRowRef } from "@src/types/table"; + +export const useSubTableData = ( + column: ColumnConfig, + row: TableRow, + _rowy_ref: TableRowRef +) => { + const label = (column.config?.parentLabel ?? []).reduce((acc, curr) => { + if (acc !== "") return `${acc} - ${row[curr]}`; + else return row[curr]; + }, ""); + + const documentCount: string = row[column.fieldName]?.count ?? ""; + + const location = useLocation(); + const rootTablePath = decodeURIComponent( + location.pathname.split("/" + ROUTES.subTable)[0] + ); + + // Get params from URL: /table/:tableId/arraySubTable/:docPath/:arraySubTableKey + let subTablePath = [ + rootTablePath, + ROUTES.arraySubTable, + encodeURIComponent(_rowy_ref.path), + column.key, + ].join("/"); + + subTablePath += "?parentLabel=" + encodeURIComponent(label ?? ""); + + return { documentCount, label, subTablePath }; +}; diff --git a/src/components/fields/CreatedAt/index.tsx b/src/components/fields/CreatedAt/index.tsx index cffa0ea8..a6dab174 100644 --- a/src/components/fields/CreatedAt/index.tsx +++ b/src/components/fields/CreatedAt/index.tsx @@ -27,5 +27,6 @@ export const config: IFieldConfig = { TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, + requireCollectionTable: true, }; export default config; diff --git a/src/components/fields/CreatedBy/index.tsx b/src/components/fields/CreatedBy/index.tsx index 98fe8cb6..257da871 100644 --- a/src/components/fields/CreatedBy/index.tsx +++ b/src/components/fields/CreatedBy/index.tsx @@ -28,5 +28,6 @@ export const config: IFieldConfig = { TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, + requireCollectionTable: true, }; export default config; diff --git a/src/components/fields/Derivative/index.tsx b/src/components/fields/Derivative/index.tsx index 4c18a0f3..a7ea0600 100644 --- a/src/components/fields/Derivative/index.tsx +++ b/src/components/fields/Derivative/index.tsx @@ -22,5 +22,6 @@ export const config: IFieldConfig = { settingsValidator, requireConfiguration: true, requireCloudFunction: true, + requireCollectionTable: true, }; export default config; diff --git a/src/components/fields/File/EditorCell.tsx b/src/components/fields/File/EditorCell.tsx index a520c594..c175cfc8 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"; @@ -22,11 +21,17 @@ export default function File_({ _rowy_ref, tabIndex, rowHeight, + row: { _rowy_arrayTableData }, }: IEditorCellProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload(_rowy_ref, column.key, { multiple: true }); + useFileUpload( + _rowy_ref, + column.key, + { multiple: true }, + _rowy_arrayTableData + ); const { isDragActive, getRootProps, getInputProps } = dropzoneState; const dropzoneProps = getRootProps(); diff --git a/src/components/fields/File/SideDrawerField.tsx b/src/components/fields/File/SideDrawerField.tsx index 00287c23..38be7a84 100644 --- a/src/components/fields/File/SideDrawerField.tsx +++ b/src/components/fields/File/SideDrawerField.tsx @@ -25,10 +25,16 @@ export default function File_({ _rowy_ref, value, disabled, + _rowy_arrayTableData, }: ISideDrawerFieldProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload(_rowy_ref, column.key, { multiple: true }); + useFileUpload( + _rowy_ref, + column.key, + { multiple: true }, + _rowy_arrayTableData + ); const { isDragActive, getRootProps, getInputProps } = dropzoneState; diff --git a/src/components/fields/File/useFileUpload.ts b/src/components/fields/File/useFileUpload.ts index d99ccf67..a5305cab 100644 --- a/src/components/fields/File/useFileUpload.ts +++ b/src/components/fields/File/useFileUpload.ts @@ -5,12 +5,17 @@ import { DropzoneOptions, useDropzone } from "react-dropzone"; import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; import useUploader from "@src/hooks/useFirebaseStorageUploader"; -import type { FileValue, TableRowRef } from "@src/types/table"; +import type { + ArrayTableRowData, + FileValue, + TableRowRef, +} from "@src/types/table"; export default function useFileUpload( docRef: TableRowRef, fieldName: string, - dropzoneOptions: DropzoneOptions = {} + dropzoneOptions: DropzoneOptions = {}, + arrayTableData?: ArrayTableRowData ) { const updateField = useSetAtom(updateFieldAtom, tableScope); const { uploaderState, upload, deleteUpload } = useUploader(); @@ -47,7 +52,9 @@ export default function useFileUpload( async (files: File[]) => { const { uploads, failures } = await upload({ docRef, - fieldName, + fieldName: arrayTableData + ? `${arrayTableData?.parentField}/${fieldName}` + : fieldName, files, }); updateField({ @@ -55,10 +62,11 @@ export default function useFileUpload( fieldName, value: uploads, useArrayUnion: true, + arrayTableData, }); return { uploads, failures }; }, - [docRef, fieldName, updateField, upload] + [arrayTableData, docRef, fieldName, updateField, upload] ); const handleDelete = useCallback( @@ -69,10 +77,11 @@ export default function useFileUpload( value: [file], useArrayRemove: true, disableCheckEquality: true, + arrayTableData, }); deleteUpload(file); }, - [deleteUpload, docRef, fieldName, updateField] + [arrayTableData, deleteUpload, docRef.path, fieldName, updateField] ); return { diff --git a/src/components/fields/Image/EditorCell.tsx b/src/components/fields/Image/EditorCell.tsx index ceec4307..d79d1b0f 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,8 +11,6 @@ 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"; @@ -25,14 +23,20 @@ export default function Image_({ _rowy_ref, tabIndex, rowHeight, + row: { _rowy_arrayTableData }, }: IEditorCellProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload(_rowy_ref, column.key, { - multiple: true, - accept: IMAGE_MIME_TYPES, - }); + useFileUpload( + _rowy_ref, + column.key, + { + multiple: true, + accept: IMAGE_MIME_TYPES, + }, + _rowy_arrayTableData + ); const localImages = useMemo( () => diff --git a/src/components/fields/Image/SideDrawerField.tsx b/src/components/fields/Image/SideDrawerField.tsx index 70c58b88..a21af2c2 100644 --- a/src/components/fields/Image/SideDrawerField.tsx +++ b/src/components/fields/Image/SideDrawerField.tsx @@ -84,6 +84,7 @@ export default function Image_({ _rowy_ref, value, disabled, + _rowy_arrayTableData, }: ISideDrawerFieldProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); @@ -94,10 +95,15 @@ export default function Image_({ uploaderState, localFiles, dropzoneState, - } = useFileUpload(_rowy_ref, column.key, { - multiple: true, - accept: IMAGE_MIME_TYPES, - }); + } = useFileUpload( + _rowy_ref, + column.key, + { + multiple: true, + accept: IMAGE_MIME_TYPES, + }, + _rowy_arrayTableData + ); const localImages = useMemo( () => diff --git a/src/components/fields/SubTable/index.tsx b/src/components/fields/SubTable/index.tsx index 7e153771..574c0a2e 100644 --- a/src/components/fields/SubTable/index.tsx +++ b/src/components/fields/SubTable/index.tsx @@ -31,5 +31,6 @@ export const config: IFieldConfig = { SideDrawerField, initializable: false, requireConfiguration: true, + requireCollectionTable: true, }; export default config; diff --git a/src/components/fields/UpdatedAt/index.tsx b/src/components/fields/UpdatedAt/index.tsx index d6e5eb92..1375347b 100644 --- a/src/components/fields/UpdatedAt/index.tsx +++ b/src/components/fields/UpdatedAt/index.tsx @@ -28,5 +28,6 @@ export const config: IFieldConfig = { TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, + requireCollectionTable: true, }; export default config; diff --git a/src/components/fields/UpdatedBy/index.tsx b/src/components/fields/UpdatedBy/index.tsx index 4b1c3a42..c4f733d1 100644 --- a/src/components/fields/UpdatedBy/index.tsx +++ b/src/components/fields/UpdatedBy/index.tsx @@ -29,5 +29,6 @@ export const config: IFieldConfig = { TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, + requireCollectionTable: true, }; export default config; diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts index 0d54b0a5..97726ea0 100644 --- a/src/components/fields/index.ts +++ b/src/components/fields/index.ts @@ -26,6 +26,7 @@ import Image_ from "./Image"; import File_ from "./File"; import Connector from "./Connector"; import SubTable from "./SubTable"; +import ArraySubTable from "./ArraySubTable"; import Reference from "./Reference"; import ConnectTable from "./ConnectTable"; import ConnectService from "./ConnectService"; @@ -74,6 +75,7 @@ export const FIELDS: IFieldConfig[] = [ File_, /** CONNECTION */ Connector, + ArraySubTable, SubTable, Reference, ConnectTable, diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts index a11e1e10..ffc552f2 100644 --- a/src/components/fields/types.ts +++ b/src/components/fields/types.ts @@ -6,6 +6,7 @@ import type { TableRow, TableRowRef, TableFilter, + ArrayTableRowData, } from "@src/types/table"; import type { SelectedCell } from "@src/atoms/tableScope"; import type { IContextMenuItem } from "@src/components/Table/ContextMenu/ContextMenuItem"; @@ -20,6 +21,7 @@ export interface IFieldConfig { initializable?: boolean; requireConfiguration?: boolean; requireCloudFunction?: boolean; + requireCollectionTable?: boolean; initialValue: any; icon?: React.ReactNode; description?: string; @@ -80,7 +82,8 @@ export interface ISideDrawerFieldProps { column: ColumnConfig; /** The row’s _rowy_ref object */ _rowy_ref: TableRowRef; - + /** The array table row’s data */ + _rowy_arrayTableData?: ArrayTableRowData; /** The field’s local value – synced with db when field is not dirty */ value: T; /** Call when the user has input but changes have not been saved */ diff --git a/src/constants/fields.ts b/src/constants/fields.ts index 900b88db..d91111a4 100644 --- a/src/constants/fields.ts +++ b/src/constants/fields.ts @@ -28,6 +28,7 @@ export enum FieldType { // CONNECTION connector = "CONNECTOR", subTable = "SUB_TABLE", + arraySubTable = "ARRAY_SUB_TABLE", reference = "REFERENCE", connectTable = "DOCUMENT_SELECT", connectService = "SERVICE_SELECT", diff --git a/src/constants/routes.tsx b/src/constants/routes.tsx index af23e2cd..70e8d9eb 100644 --- a/src/constants/routes.tsx +++ b/src/constants/routes.tsx @@ -25,8 +25,10 @@ export enum ROUTES { tableWithId = "/table/:id", /** Nested route: `/table/:id/subTable/...` */ subTable = "subTable", + arraySubTable = "arraySubTable", /** Nested route: `/table/:id/subTable/...` */ subTableWithId = "subTable/:docPath/:subTableKey", + arraySubTableWithId = "arraySubTable/:docPath/:subTableKey", /** @deprecated Redirects to /table */ tableGroup = "/tableGroup", /** @deprecated Redirects to /table */ diff --git a/src/hooks/useFirestoreDocAsCollectionWithAtom.ts b/src/hooks/useFirestoreDocAsCollectionWithAtom.ts new file mode 100644 index 00000000..58affa18 --- /dev/null +++ b/src/hooks/useFirestoreDocAsCollectionWithAtom.ts @@ -0,0 +1,357 @@ +import { useCallback, useEffect } from "react"; +import useMemoValue from "use-memo-value"; +import { useAtom, PrimitiveAtom, useSetAtom } from "jotai"; +import { orderBy } from "lodash-es"; +import { useSnackbar } from "notistack"; + +import { + Firestore, + doc, + refEqual, + onSnapshot, + FirestoreError, + setDoc, + DocumentReference, +} from "firebase/firestore"; +import { useErrorHandler } from "react-error-boundary"; + +import { projectScope } from "@src/atoms/projectScope"; +import { + ArrayTableRowData, + DeleteCollectionDocFunction, + TableRow, + TableSort, + UpdateCollectionDocFunction, +} from "@src/types/table"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; +import { omitRowyFields } from "@src/utils/table"; + +/** Options for {@link useFirestoreDocWithAtom} */ +interface IUseFirestoreDocWithAtomOptions { + /** Called when an error occurs. Make sure to wrap in useCallback! If not provided, errors trigger the nearest ErrorBoundary. */ + onError?: (error: FirestoreError) => void; + /** Optionally disable Suspense */ + disableSuspense?: boolean; + /** Optionally create the document if it doesn’t exist with the following data */ + createIfNonExistent?: T; + /** Set this atom’s value to a function that updates the document. Uses same scope as `dataScope`. */ + // updateDataAtom?: PrimitiveAtom | undefined>; + updateDocAtom?: PrimitiveAtom | undefined>; + deleteDocAtom?: PrimitiveAtom; + sorts?: TableSort[]; +} + +/** + * Attaches a listener for a Firestore document and unsubscribes on unmount. + * Gets the Firestore instance initiated in projectScope. + * Updates an atom and Suspends that atom until the first snapshot is received. + * + * @param dataAtom - Atom to store data in + * @param dataScope - Atom scope + * @param path - Document path. If falsy, the listener isn’t created at all. + * @param fieldName - Parent field name + * @param options - {@link IUseFirestoreDocWithAtomOptions} + */ +export function useFirestoreDocAsCollectionWithAtom( + dataAtom: PrimitiveAtom, + dataScope: Parameters[1] | undefined, + path: string, + fieldName: string, + options: IUseFirestoreDocWithAtomOptions +) { + // Destructure options so they can be used as useEffect dependencies + const { + onError, + disableSuspense, + createIfNonExistent, + updateDocAtom, + deleteDocAtom, + sorts, + } = options || {}; + + const [firebaseDb] = useAtom(firebaseDbAtom, projectScope); + const setDataAtom = useSetAtom(dataAtom, dataScope); + + const handleError = useErrorHandler(); + const { enqueueSnackbar } = useSnackbar(); + const setUpdateDocAtom = useSetAtom( + updateDocAtom || (dataAtom as any), + dataScope + ); + const setDeleteRowAtom = useSetAtom( + deleteDocAtom || (dataAtom as any), + dataScope + ); + + // Create the doc ref and memoize using Firestore’s refEqual + const memoizedDocRef = useMemoValue( + getDocRef(firebaseDb, path), + (next, prev) => refEqual(next as any, prev as any) + ); + + useEffect(() => { + // If path is invalid and no memoizedDocRef was created, don’t continue + if (!memoizedDocRef) return; + + // Suspend data atom until we get the first snapshot + let suspended = false; + if (!disableSuspense) { + setDataAtom(new Promise(() => []) as unknown as T[]); + suspended = true; + } + + // Create a listener for the document + const unsubscribe = onSnapshot( + memoizedDocRef, + { includeMetadataChanges: true }, + (docSnapshot) => { + try { + if (docSnapshot.exists() && docSnapshot.data() !== undefined) { + const pseudoDoc = docSnapshot.get(fieldName) || []; + const pseudoRow = pseudoDoc.map((row: any, i: number) => { + return { + ...row, + _rowy_ref: docSnapshot.ref, + _rowy_arrayTableData: { + index: i, + parentField: fieldName, + }, + }; + }); + const sorted = sortRows(pseudoRow, sorts); + setDataAtom(sorted); + } else { + enqueueSnackbar(`Array table doesn't exist`, { + variant: "error", + }); + // console.log("docSnapshot", docSnapshot.data()); + // setDataAtom([] as T[]); + } + } catch (error) { + if (onError) onError(error as FirestoreError); + else handleError(error); + } + suspended = false; + }, + (error) => { + if (suspended) setDataAtom([] as T[]); + if (onError) onError(error); + else handleError(error); + } + ); + + // When the listener will change, unsubscribe + return () => { + unsubscribe(); + }; + }, [ + memoizedDocRef, + onError, + setDataAtom, + disableSuspense, + createIfNonExistent, + handleError, + fieldName, + sorts, + enqueueSnackbar, + ]); + + const setRows = useCallback( + (rows: T[]) => { + rows = rows.map((row: any, i: number) => omitRowyFields(row)); + if (!fieldName) return; + try { + return setDoc( + doc(firebaseDb, path), + { [fieldName]: rows }, + { merge: true } + ); + } catch (error) { + enqueueSnackbar(`Error updating array table`, { + variant: "error", + }); + return; + } + }, + [enqueueSnackbar, fieldName, firebaseDb, path] + ); + + useEffect(() => { + if (deleteDocAtom) { + setDeleteRowAtom(() => (_: string, options?: ArrayTableRowData) => { + if (!options) return; + + const deleteRow = () => { + let temp: T[] = []; + setDataAtom((prevData) => { + temp = unsortRows(prevData); + temp.splice(options.index, 1); + for (let i = options.index; i < temp.length; i++) { + // @ts-ignore + temp[i]._rowy_arrayTableData.index = i; + } + return sortRows(temp, sorts); + }); + return setRows(temp); + }; + deleteRow(); + }); + } + }, [ + deleteDocAtom, + firebaseDb, + path, + setDataAtom, + setDeleteRowAtom, + setRows, + sorts, + ]); + + useEffect(() => { + if (updateDocAtom) { + setUpdateDocAtom( + () => + ( + path_: string, + update: T, + deleteFields?: string[], + options?: ArrayTableRowData + ) => { + if (options === undefined) return; + + const deleteRowFields = () => { + let temp: T[] = []; + setDataAtom((prevData) => { + temp = unsortRows(prevData); + + if (deleteFields === undefined) return prevData; + + temp[options.index] = { + ...temp[options.index], + ...deleteFields?.reduce( + (acc, field) => ({ ...acc, [field]: undefined }), + {} + ), + }; + + return sortRows(temp, sorts); + }); + + return setRows(temp); + }; + + const updateRowValues = () => { + let temp: T[] = []; + setDataAtom((prevData) => { + temp = unsortRows(prevData); + + temp[options.index] = { + ...temp[options.index], + ...update, + }; + return sortRows(temp, sorts); + }); + return setRows(temp); + }; + + const addNewRow = (addTo: "top" | "bottom", base?: TableRow) => { + let temp: T[] = []; + + const newRow = (i: number) => + ({ + ...base, + _rowy_ref: { + id: doc(firebaseDb, path).id, + path: doc(firebaseDb, path).path, + }, + _rowy_arrayTableData: { + index: i, + parentField: fieldName, + }, + } as T); + + setDataAtom((prevData) => { + temp = unsortRows(prevData); + + if (addTo === "bottom") { + temp.push(newRow(prevData.length)); + } else { + const modifiedPrevData = temp.map((row: any, i: number) => { + return { + ...row, + _rowy_arrayTableData: { + index: i + 1, + }, + }; + }); + temp = [newRow(0), ...modifiedPrevData]; + } + return sortRows(temp, sorts); + }); + + return setRows(temp); + }; + + if (Array.isArray(deleteFields) && deleteFields.length > 0) { + return deleteRowFields(); + } else if (options.operation?.addRow) { + return addNewRow( + options.operation.addRow, + options?.operation.base + ); + } else { + return updateRowValues(); + } + } + ); + } + }, [ + fieldName, + firebaseDb, + path, + setDataAtom, + setRows, + setUpdateDocAtom, + sorts, + updateDocAtom, + ]); +} + +export default useFirestoreDocAsCollectionWithAtom; + +/** + * Create the Firestore document reference. + * Put code in a function so the results can be compared by useMemoValue. + */ +export const getDocRef = ( + firebaseDb: Firestore, + path: string | undefined, + pathSegments?: Array +) => { + if (!path || (Array.isArray(pathSegments) && pathSegments?.some((x) => !x))) + return null; + + return doc( + firebaseDb, + path, + ...((pathSegments as string[]) || []) + ) as DocumentReference; +}; + +function sortRows( + rows: T[], + sorts: TableSort[] | undefined +): T[] { + if (sorts === undefined || sorts.length < 1) { + return rows; + } + + const order: "asc" | "desc" = + sorts[0].direction === undefined ? "asc" : sorts[0].direction; + + return orderBy(rows, [sorts[0].key], [order]); +} + +function unsortRows(rows: T[]): T[] { + return orderBy(rows, ["_rowy_arrayTableData.index"], ["asc"]); +} diff --git a/src/pages/Table/ProvidedArraySubTablePage.tsx b/src/pages/Table/ProvidedArraySubTablePage.tsx new file mode 100644 index 00000000..1e6e256c --- /dev/null +++ b/src/pages/Table/ProvidedArraySubTablePage.tsx @@ -0,0 +1,156 @@ +import { lazy, Suspense, useMemo } from "react"; +import { useAtom, Provider } from "jotai"; +import { selectAtom } from "jotai/utils"; +import { DebugAtoms } from "@src/atoms/utils"; +import { ErrorBoundary } from "react-error-boundary"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { find, isEqual } from "lodash-es"; + +import Modal from "@src/components/Modal"; +import BreadcrumbsSubTable from "@src/components/Table/Breadcrumbs/BreadcrumbsSubTable"; +import ErrorFallback from "@src/components/ErrorFallback"; +import ArraySubTableSourceFirestore from "@src/sources/TableSourceFirestore/ArraySubTableSourceFirestore"; +import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton"; +import TableSkeleton from "@src/components/Table/TableSkeleton"; + +import { projectScope, currentUserAtom } from "@src/atoms/projectScope"; +import { + tableScope, + tableIdAtom, + tableSettingsAtom, + tableSchemaAtom, +} from "@src/atoms/tableScope"; +import { ROUTES } from "@src/constants/routes"; +import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; +import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar"; + +// prettier-ignore +const TablePage = lazy(() => import("./TablePage" /* webpackChunkName: "TablePage" */)); + +/** + * Wraps `TablePage` with the data for a array-sub-table. + * + * Differences to `ProvidedTablePage`: + * - Renders a `Modal` + * - When this is a child of `ProvidedTablePage`, the `TablePage` rendered for + * the root table has its modals disabled + */ +export default function ProvidedArraySubTablePage() { + const location = useLocation(); + const navigate = useNavigate(); + // Get params from URL: /arraySubTable/:docPath/:subTableKey + const { docPath, subTableKey } = useParams(); + + const [currentUser] = useAtom(currentUserAtom, projectScope); + + // Get table settings and the source column from root table + const [rootTableSettings] = useAtom(tableSettingsAtom, tableScope); + const [sourceColumn] = useAtom( + useMemo( + () => + selectAtom( + tableSchemaAtom, + (tableSchema) => find(tableSchema.columns, ["key", subTableKey]), + isEqual + ), + [subTableKey] + ), + tableScope + ); + + // Consumed by children as `tableSettings.collection` + const subTableCollection = docPath ?? ""; // + "/" + (sourceColumn?.fieldName || subTableKey); + + // Must be compatible with `getTableSchemaPath`: tableId/rowId/subTableKey + // This is why we can’t have a sub-table column fieldName !== key + const subTableId = + docPath?.replace(rootTableSettings.collection, rootTableSettings.id) + + "/" + + subTableKey; + + // Write fake tableSettings + const subTableSettings = { + ...rootTableSettings, + collection: subTableCollection, + id: subTableId, + subTableKey, + isNotACollection: true, + tableType: "primaryCollection" as "primaryCollection", + name: sourceColumn?.name || subTableKey || "", + }; + + const rootTableLink = location.pathname.split("/" + ROUTES.arraySubTable)[0]; + + return ( + + } + onClose={() => navigate(rootTableLink)} + disableBackdropClick + disableEscapeKeyDown + fullScreen + sx={{ + "& > .MuiDialog-container > .MuiPaper-root": { + bgcolor: "background.default", + backgroundImage: "none", + }, + "& .modal-title-row": { + height: TOP_BAR_HEIGHT, + "& .MuiDialogTitle-root": { + px: 2, + py: (TOP_BAR_HEIGHT - 28) / 2 / 8, + }, + "& .dialog-close": { m: (TOP_BAR_HEIGHT - 40) / 2 / 8, ml: -1 }, + }, + "& .table-container": { + height: `calc(100vh - ${TOP_BAR_HEIGHT}px - ${TABLE_TOOLBAR_HEIGHT}px - 16px)`, + }, + }} + ScrollableDialogContentProps={{ + disableTopDivider: true, + disableBottomDivider: true, + style: { "--dialog-spacing": 0, "--dialog-contents-spacing": 0 } as any, + }} + BackdropProps={{ key: "sub-table-modal-backdrop" }} + > + + + + + + } + > + + + + + + + + + ); +} diff --git a/src/pages/Table/TablePage.tsx b/src/pages/Table/TablePage.tsx index 04fc0e61..d8e85c85 100644 --- a/src/pages/Table/TablePage.tsx +++ b/src/pages/Table/TablePage.tsx @@ -41,6 +41,7 @@ import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar"; import { DRAWER_COLLAPSED_WIDTH } from "@src/components/SideDrawer"; import { formatSubTableName } from "@src/utils/table"; +import { TableToolsType } from "@src/types/table"; // prettier-ignore const BuildLogsSnack = lazy(() => import("@src/components/TableModals/CloudLogsModal/BuildLogs/BuildLogsSnack" /* webpackChunkName: "TableModals-BuildLogsSnack" */)); @@ -53,6 +54,10 @@ export interface ITablePageProps { disableModals?: boolean; /** Disable side drawer */ disableSideDrawer?: boolean; + /* Array table is not a collection */ + tableNotACollection?: boolean; + + disabledTools?: TableToolsType; } /** @@ -71,6 +76,8 @@ export interface ITablePageProps { export default function TablePage({ disableModals, disableSideDrawer, + tableNotACollection, + disabledTools, }: ITablePageProps) { const [userRoles] = useAtom(userRolesAtom, projectScope); const [userSettings] = useAtom(userSettingsAtom, projectScope); @@ -127,7 +134,7 @@ export default function TablePage({ }> - + diff --git a/src/sources/TableSourceFirestore/ArraySubTableSourceFirestore.tsx b/src/sources/TableSourceFirestore/ArraySubTableSourceFirestore.tsx new file mode 100644 index 00000000..e7920b01 --- /dev/null +++ b/src/sources/TableSourceFirestore/ArraySubTableSourceFirestore.tsx @@ -0,0 +1,143 @@ +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, + tableSettingsAtom, + tableSchemaAtom, + updateTableSchemaAtom, + tableSortsAtom, + tableRowsDbAtom, + _updateRowDbAtom, + _deleteRowDbAtom, + tableNextPageAtom, +} from "@src/atoms/tableScope"; + +import useFirestoreDocWithAtom, { + getDocRef, +} from "@src/hooks/useFirestoreDocWithAtom"; + +import useAuditChange from "./useAuditChange"; +import useBulkWriteDb from "./useBulkWriteDb"; +import { handleFirestoreError } from "./handleFirestoreError"; + +import { getTableSchemaPath } from "@src/utils/table"; +import { TableRow, TableSchema } from "@src/types/table"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; +import { projectScope } from "@src/atoms/projectScope"; +import useFirestoreDocAsCollectionWithAtom from "@src/hooks/useFirestoreDocAsCollectionWithAtom"; + +/** + * When rendered, provides atom values for top-level tables and sub-tables + */ +export const TableSourceFirestore2 = memo(function TableSourceFirestore() { + const [firebaseDb] = useAtom(firebaseDbAtom, projectScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const setTableSchema = useSetAtom(tableSchemaAtom, tableScope); + const setUpdateTableSchema = useSetAtom(updateTableSchemaAtom, tableScope); + const setTableNextPage = useSetAtom(tableNextPageAtom, 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) + ); + + setTableNextPage({ + loading: false, + available: false, + }); + 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( + tableSchemaAtom, + tableScope, + getTableSchemaPath(tableSettings), + { + createIfNonExistent: { columns: {} }, + disableSuspense: true, + } + ); + + // Get table sorts + const [sorts] = useAtom(tableSortsAtom, tableScope); + // Get documents from collection and store in tableRowsDbAtom + // and handle some errors with snackbars + const elevateError = useErrorHandler(); + const handleErrorCallback = useCallback( + (error: FirestoreError) => + handleFirestoreError(error, enqueueSnackbar, elevateError), + [enqueueSnackbar, elevateError] + ); + useFirestoreDocAsCollectionWithAtom( + tableRowsDbAtom, + tableScope, + tableSettings.collection, + tableSettings.subTableKey || "", + { + sorts, + onError: handleErrorCallback, + updateDocAtom: _updateRowDbAtom, + deleteDocAtom: _deleteRowDbAtom, + } + ); + useAuditChange(); + useBulkWriteDb(); + + return null; +}); + +export default TableSourceFirestore2; diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 46a5a939..7b8787fd 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -31,7 +31,8 @@ export type UpdateDocFunction = ( export type UpdateCollectionDocFunction = ( path: string, update: Partial, - deleteFields?: string[] + deleteFields?: string[], + options?: ArrayTableRowData ) => Promise; /** @@ -39,7 +40,10 @@ export type UpdateCollectionDocFunction = ( * @param path - The full path to the doc * @returns Promise */ -export type DeleteCollectionDocFunction = (path: string) => Promise; +export type DeleteCollectionDocFunction = ( + path: string, + options?: ArrayTableRowData +) => Promise; export type BulkWriteOperation = | { type: "delete"; path: string } @@ -71,6 +75,8 @@ export type TableSettings = { /** Roles that can see this table in the UI and navigate. Firestore Rules need to be set to give access to the data */ roles: string[]; + isNotACollection?: boolean; + subTableKey?: string | undefined; section: string; description?: string; details?: string; @@ -187,6 +193,15 @@ export type TableFilter = { value: any; }; +export const TableTools = [ + "import", + "export", + "webhooks", + "extensions", + "cloud_logs", +] as const; +export type TableToolsType = typeof Tools[number]; + export type TableSort = { key: string; direction: Parameters[1]; @@ -197,10 +212,20 @@ export type TableRowRef = { path: string; } & Partial; +type ArrayTableOperations = { + addRow?: "top" | "bottom"; + base?: TableRow; +}; +export type ArrayTableRowData = { + index: number; + parentField?: string; + operation?: ArrayTableOperations; +}; export type TableRow = DocumentData & { _rowy_ref: TableRowRef; _rowy_missingRequiredFields?: string[]; _rowy_outOfOrder?: boolean; + _rowy_arrayTableData?: ArrayTableRowData; }; export type FileValue = { diff --git a/src/utils/table.ts b/src/utils/table.ts index 691dd385..7a143a8b 100644 --- a/src/utils/table.ts +++ b/src/utils/table.ts @@ -51,6 +51,7 @@ export const omitRowyFields = >(row: T) => { delete shallowClonedRow["_rowy_outOfOrder"]; delete shallowClonedRow["_rowy_missingRequiredFields"]; delete shallowClonedRow["_rowy_new"]; + delete shallowClonedRow["_rowy_arrayTableData"]; return shallowClonedRow as T; }; From dd481415fca4d782ebc4c611aca8b61c6ea6362a Mon Sep 17 00:00:00 2001 From: Anish Roy <6275anishroy@gmail.com> Date: Fri, 14 Apr 2023 17:56:12 +0530 Subject: [PATCH 3/8] removed _rowy_arrayTableData and expanded _rowy_ref --- src/components/SideDrawer/MemoizedField.tsx | 5 +-- src/components/SideDrawer/SideDrawer.tsx | 7 +++-- .../SideDrawer/SideDrawerFields.tsx | 7 ++--- .../Table/ContextMenu/MenuContents.tsx | 12 +++---- .../Table/FinalColumn/FinalColumn.tsx | 10 +++--- src/components/Table/Table.tsx | 2 -- src/components/Table/TableBody.tsx | 2 +- .../Table/TableCell/EditorCellController.tsx | 2 +- src/components/Table/TableCell/TableCell.tsx | 8 ++--- .../Table/useKeyboardNavigation.tsx | 2 +- src/components/Table/useMenuAction.tsx | 2 +- src/components/fields/File/EditorCell.tsx | 8 +---- .../fields/File/SideDrawerField.tsx | 8 +---- src/components/fields/File/useFileUpload.ts | 21 +++++-------- src/components/fields/Image/EditorCell.tsx | 14 +++------ .../fields/Image/SideDrawerField.tsx | 14 +++------ src/components/fields/types.ts | 3 -- .../useFirestoreDocAsCollectionWithAtom.ts | 31 ++++++++++++------- src/types/table.d.ts | 14 +++++---- src/utils/table.ts | 1 - 20 files changed, 72 insertions(+), 101 deletions(-) diff --git a/src/components/SideDrawer/MemoizedField.tsx b/src/components/SideDrawer/MemoizedField.tsx index 8f7b7766..0fb62f62 100644 --- a/src/components/SideDrawer/MemoizedField.tsx +++ b/src/components/SideDrawer/MemoizedField.tsx @@ -5,7 +5,7 @@ import { isEqual, isEmpty } from "lodash-es"; import FieldWrapper from "./FieldWrapper"; import { IFieldConfig } from "@src/components/fields/types"; import { getFieldProp } from "@src/components/fields"; -import { ArrayTableRowData, ColumnConfig, TableRowRef } from "@src/types/table"; +import { ColumnConfig, TableRowRef } from "@src/types/table"; export interface IMemoizedFieldProps { field: ColumnConfig; @@ -13,7 +13,6 @@ export interface IMemoizedFieldProps { hidden: boolean; value: any; _rowy_ref: TableRowRef; - _rowy_arrayTableData?: ArrayTableRowData; isDirty: boolean; onDirty: (fieldName: string) => void; onSubmit: (fieldName: string, value: any) => void; @@ -26,7 +25,6 @@ export const MemoizedField = memo( hidden, value, _rowy_ref, - _rowy_arrayTableData, isDirty, onDirty, onSubmit, @@ -80,7 +78,6 @@ export const MemoizedField = memo( }, onSubmit: handleSubmit, disabled, - _rowy_arrayTableData, })} ); diff --git a/src/components/SideDrawer/SideDrawer.tsx b/src/components/SideDrawer/SideDrawer.tsx index faeb15ad..49df7339 100644 --- a/src/components/SideDrawer/SideDrawer.tsx +++ b/src/components/SideDrawer/SideDrawer.tsx @@ -35,7 +35,7 @@ export default function SideDrawer() { cell?.arrayIndex === undefined ? ["_rowy_ref.path", cell?.path] : // if the table is an array table, we need to use the array index to find the row - ["_rowy_arrayTableData.index", cell?.arrayIndex] + ["_rowy_ref.arrayTableData.index", cell?.arrayIndex] ); const selectedCellRowIndex = findIndex( @@ -43,7 +43,7 @@ export default function SideDrawer() { cell?.arrayIndex === undefined ? ["_rowy_ref.path", cell?.path] : // if the table is an array table, we need to use the array index to find the row - ["_rowy_arrayTableData.index", cell?.arrayIndex] + ["_rowy_ref.arrayTableData.index", cell?.arrayIndex] ); const handleNavigate = (direction: "up" | "down") => () => { @@ -55,8 +55,9 @@ export default function SideDrawer() { setCell((cell) => ({ columnKey: cell!.columnKey, - path: newPath, + path: cell?.arrayIndex !== undefined ? cell.path : newPath, focusInside: false, + arrayIndex: cell?.arrayIndex !== undefined ? rowIndex : undefined, })); }; diff --git a/src/components/SideDrawer/SideDrawerFields.tsx b/src/components/SideDrawer/SideDrawerFields.tsx index e2abdfa4..2f1e0b8a 100644 --- a/src/components/SideDrawer/SideDrawerFields.tsx +++ b/src/components/SideDrawer/SideDrawerFields.tsx @@ -130,7 +130,6 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) { onDirty={onDirty} onSubmit={onSubmit} isDirty={dirtyField === field.key} - _rowy_arrayTableData={row._rowy_arrayTableData} /> ))} @@ -139,12 +138,12 @@ export default function SideDrawerFields({ row }: ISideDrawerFieldsProps) { fieldName="_rowy_ref.path" label="Document path" debugText={ - row._rowy_arrayTableData + row._rowy_ref.arrayTableData ? row._rowy_ref.path + " → " + - row._rowy_arrayTableData.parentField + + row._rowy_ref.arrayTableData.parentField + "[" + - row._rowy_arrayTableData.index + + row._rowy_ref.arrayTableData.index + "]" : row._rowy_ref.path } diff --git a/src/components/Table/ContextMenu/MenuContents.tsx b/src/components/Table/ContextMenu/MenuContents.tsx index 5154ba8b..5437c2dd 100644 --- a/src/components/Table/ContextMenu/MenuContents.tsx +++ b/src/components/Table/ContextMenu/MenuContents.tsx @@ -69,7 +69,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { selectedCell?.arrayIndex === undefined ? ["_rowy_ref.path", selectedCell.path] : // if the table is an array table, we need to use the array index to find the row - ["_rowy_arrayTableData.index", selectedCell.arrayIndex] + ["_rowy_ref.arrayTableData.index", selectedCell.arrayIndex] ); if (!row) return null; @@ -78,11 +78,11 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { const handleDuplicate = () => { const _duplicate = () => { - if (row._rowy_arrayTableData !== undefined) { + if (row._rowy_ref.arrayTableData !== undefined) { if (!updateRowDb) return; return updateRowDb("", {}, undefined, { - index: row._rowy_arrayTableData.index, + index: row._rowy_ref.arrayTableData.index, operation: { addRow: "bottom", base: row, @@ -95,7 +95,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { }); }; - if (altPress || row._rowy_arrayTableData !== undefined) { + if (altPress || row._rowy_ref.arrayTableData !== undefined) { _duplicate(); } else { confirm({ @@ -118,10 +118,10 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { const _delete = () => deleteRow({ path: row._rowy_ref.path, - options: row._rowy_arrayTableData, + options: row._rowy_ref.arrayTableData, }); - if (altPress || row._rowy_arrayTableData !== undefined) { + if (altPress || row._rowy_ref.arrayTableData !== undefined) { _delete(); } else { confirm({ diff --git a/src/components/Table/FinalColumn/FinalColumn.tsx b/src/components/Table/FinalColumn/FinalColumn.tsx index 041d3f0f..16f6ee68 100644 --- a/src/components/Table/FinalColumn/FinalColumn.tsx +++ b/src/components/Table/FinalColumn/FinalColumn.tsx @@ -43,9 +43,9 @@ export const FinalColumn = memo(function FinalColumn({ const _delete = () => deleteRow({ path: row.original._rowy_ref.path, - options: row.original._rowy_arrayTableData, + options: row.original._rowy_ref.arrayTableData, }); - if (altPress || row.original._rowy_arrayTableData !== undefined) { + if (altPress || row.original._rowy_ref.arrayTableData !== undefined) { _delete(); } else { confirm({ @@ -68,11 +68,11 @@ export const FinalColumn = memo(function FinalColumn({ const handleDuplicate = () => { const _duplicate = () => { - if (row.original._rowy_arrayTableData !== undefined) { + if (row.original._rowy_ref.arrayTableData !== undefined) { if (!updateRowDb) return; return updateRowDb("", {}, undefined, { - index: row.original._rowy_arrayTableData.index, + index: row.original._rowy_ref.arrayTableData.index, operation: { addRow: "bottom", base: row.original, @@ -84,7 +84,7 @@ export const FinalColumn = memo(function FinalColumn({ setId: addRowIdType === "custom" ? "decrement" : addRowIdType, }); }; - if (altPress || row.original._rowy_arrayTableData !== undefined) { + if (altPress || row.original._rowy_ref.arrayTableData !== undefined) { _duplicate(); } else { confirm({ diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index b99c1129..d930b794 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -200,8 +200,6 @@ export default function Table({ if (result.destination?.index === undefined || !result.draggableId) return; - console.log(result.draggableId, result.destination.index); - updateColumn({ key: result.draggableId, index: result.destination.index, diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index edaed491..3292ceab 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -105,7 +105,7 @@ export const TableBody = memo(function TableBody({ selectedCell?.columnKey === cell.column.id && // if the table is an array sub table, we need to check the array index as well selectedCell?.arrayIndex === - row.original._rowy_arrayTableData?.index; + row.original._rowy_ref.arrayTableData?.index; const fieldTypeGroup = getFieldProp( "group", diff --git a/src/components/Table/TableCell/EditorCellController.tsx b/src/components/Table/TableCell/EditorCellController.tsx index cacc8946..217c40eb 100644 --- a/src/components/Table/TableCell/EditorCellController.tsx +++ b/src/components/Table/TableCell/EditorCellController.tsx @@ -66,7 +66,7 @@ export default function EditorCellController({ fieldName: props.column.fieldName, value: localValueRef.current, deleteField: localValueRef.current === undefined, - arrayTableData: props.row?._rowy_arrayTableData, + arrayTableData: props.row?._rowy_ref.arrayTableData, }); } catch (e) { enqueueSnackbar((e as Error).message, { variant: "error" }); diff --git a/src/components/Table/TableCell/TableCell.tsx b/src/components/Table/TableCell/TableCell.tsx index 5c664b35..f7e501fb 100644 --- a/src/components/Table/TableCell/TableCell.tsx +++ b/src/components/Table/TableCell/TableCell.tsx @@ -123,7 +123,7 @@ export const TableCell = memo(function TableCell({ focusInsideCell, setFocusInsideCell: (focusInside: boolean) => setSelectedCell({ - arrayIndex: row.original._rowy_arrayTableData?.index, + arrayIndex: row.original._rowy_ref.arrayTableData?.index, path: row.original._rowy_ref.path, columnKey: cell.column.id, focusInside, @@ -167,7 +167,7 @@ export const TableCell = memo(function TableCell({ }} onClick={(e) => { setSelectedCell({ - arrayIndex: row.original._rowy_arrayTableData?.index, + arrayIndex: row.original._rowy_ref.arrayTableData?.index, path: row.original._rowy_ref.path, columnKey: cell.column.id, focusInside: false, @@ -176,7 +176,7 @@ export const TableCell = memo(function TableCell({ }} onDoubleClick={(e) => { setSelectedCell({ - arrayIndex: row.original._rowy_arrayTableData?.index, + arrayIndex: row.original._rowy_ref.arrayTableData?.index, path: row.original._rowy_ref.path, columnKey: cell.column.id, focusInside: true, @@ -186,7 +186,7 @@ export const TableCell = memo(function TableCell({ onContextMenu={(e) => { e.preventDefault(); setSelectedCell({ - arrayIndex: row.original._rowy_arrayTableData?.index, + arrayIndex: row.original._rowy_ref.arrayTableData?.index, path: row.original._rowy_ref.path, columnKey: cell.column.id, focusInside: false, diff --git a/src/components/Table/useKeyboardNavigation.tsx b/src/components/Table/useKeyboardNavigation.tsx index 81141a77..7c18a5b5 100644 --- a/src/components/Table/useKeyboardNavigation.tsx +++ b/src/components/Table/useKeyboardNavigation.tsx @@ -130,7 +130,7 @@ export function useKeyboardNavigation({ columnKey: leafColumns[newColIndex].id! || leafColumns[0].id!, arrayIndex: newRowIndex > -1 - ? tableRows[newRowIndex]._rowy_arrayTableData?.index + ? tableRows[newRowIndex]._rowy_ref.arrayTableData?.index : undefined, // When selected cell changes, exit current cell diff --git a/src/components/Table/useMenuAction.tsx b/src/components/Table/useMenuAction.tsx index 49cdc2d8..e7982313 100644 --- a/src/components/Table/useMenuAction.tsx +++ b/src/components/Table/useMenuAction.tsx @@ -142,7 +142,7 @@ export function useMenuAction( selectedCell.arrayIndex === undefined ? ["_rowy_ref.path", selectedCell.path] : // if the table is an array table, we need to use the array index to find the row - ["_rowy_arrayTableData.index", selectedCell.arrayIndex] + ["_rowy_ref.arrayTableData.index", selectedCell.arrayIndex] ); setCellValue(get(selectedRow, selectedCol.fieldName)); }, [selectedCell, tableSchema, tableRows]); diff --git a/src/components/fields/File/EditorCell.tsx b/src/components/fields/File/EditorCell.tsx index c175cfc8..487e9fee 100644 --- a/src/components/fields/File/EditorCell.tsx +++ b/src/components/fields/File/EditorCell.tsx @@ -21,17 +21,11 @@ export default function File_({ _rowy_ref, tabIndex, rowHeight, - row: { _rowy_arrayTableData }, }: IEditorCellProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload( - _rowy_ref, - column.key, - { multiple: true }, - _rowy_arrayTableData - ); + useFileUpload(_rowy_ref, column.key, { multiple: true }); const { isDragActive, getRootProps, getInputProps } = dropzoneState; const dropzoneProps = getRootProps(); diff --git a/src/components/fields/File/SideDrawerField.tsx b/src/components/fields/File/SideDrawerField.tsx index 38be7a84..00287c23 100644 --- a/src/components/fields/File/SideDrawerField.tsx +++ b/src/components/fields/File/SideDrawerField.tsx @@ -25,16 +25,10 @@ export default function File_({ _rowy_ref, value, disabled, - _rowy_arrayTableData, }: ISideDrawerFieldProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload( - _rowy_ref, - column.key, - { multiple: true }, - _rowy_arrayTableData - ); + useFileUpload(_rowy_ref, column.key, { multiple: true }); const { isDragActive, getRootProps, getInputProps } = dropzoneState; diff --git a/src/components/fields/File/useFileUpload.ts b/src/components/fields/File/useFileUpload.ts index a5305cab..40f4c394 100644 --- a/src/components/fields/File/useFileUpload.ts +++ b/src/components/fields/File/useFileUpload.ts @@ -5,17 +5,12 @@ import { DropzoneOptions, useDropzone } from "react-dropzone"; import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; import useUploader from "@src/hooks/useFirebaseStorageUploader"; -import type { - ArrayTableRowData, - FileValue, - TableRowRef, -} from "@src/types/table"; +import type { FileValue, TableRowRef } from "@src/types/table"; export default function useFileUpload( docRef: TableRowRef, fieldName: string, - dropzoneOptions: DropzoneOptions = {}, - arrayTableData?: ArrayTableRowData + dropzoneOptions: DropzoneOptions = {} ) { const updateField = useSetAtom(updateFieldAtom, tableScope); const { uploaderState, upload, deleteUpload } = useUploader(); @@ -52,8 +47,8 @@ export default function useFileUpload( async (files: File[]) => { const { uploads, failures } = await upload({ docRef, - fieldName: arrayTableData - ? `${arrayTableData?.parentField}/${fieldName}` + fieldName: docRef.arrayTableData + ? `${docRef.arrayTableData?.parentField}/${fieldName}` : fieldName, files, }); @@ -62,11 +57,11 @@ export default function useFileUpload( fieldName, value: uploads, useArrayUnion: true, - arrayTableData, + arrayTableData: docRef.arrayTableData, }); return { uploads, failures }; }, - [arrayTableData, docRef, fieldName, updateField, upload] + [docRef, fieldName, updateField, upload] ); const handleDelete = useCallback( @@ -77,11 +72,11 @@ export default function useFileUpload( value: [file], useArrayRemove: true, disableCheckEquality: true, - arrayTableData, + arrayTableData: docRef.arrayTableData, }); deleteUpload(file); }, - [arrayTableData, deleteUpload, docRef.path, fieldName, updateField] + [deleteUpload, docRef.arrayTableData, docRef.path, fieldName, updateField] ); return { diff --git a/src/components/fields/Image/EditorCell.tsx b/src/components/fields/Image/EditorCell.tsx index d79d1b0f..1516d96f 100644 --- a/src/components/fields/Image/EditorCell.tsx +++ b/src/components/fields/Image/EditorCell.tsx @@ -23,20 +23,14 @@ export default function Image_({ _rowy_ref, tabIndex, rowHeight, - row: { _rowy_arrayTableData }, }: IEditorCellProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload( - _rowy_ref, - column.key, - { - multiple: true, - accept: IMAGE_MIME_TYPES, - }, - _rowy_arrayTableData - ); + useFileUpload(_rowy_ref, column.key, { + multiple: true, + accept: IMAGE_MIME_TYPES, + }); const localImages = useMemo( () => diff --git a/src/components/fields/Image/SideDrawerField.tsx b/src/components/fields/Image/SideDrawerField.tsx index a21af2c2..70c58b88 100644 --- a/src/components/fields/Image/SideDrawerField.tsx +++ b/src/components/fields/Image/SideDrawerField.tsx @@ -84,7 +84,6 @@ export default function Image_({ _rowy_ref, value, disabled, - _rowy_arrayTableData, }: ISideDrawerFieldProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); @@ -95,15 +94,10 @@ export default function Image_({ uploaderState, localFiles, dropzoneState, - } = useFileUpload( - _rowy_ref, - column.key, - { - multiple: true, - accept: IMAGE_MIME_TYPES, - }, - _rowy_arrayTableData - ); + } = useFileUpload(_rowy_ref, column.key, { + multiple: true, + accept: IMAGE_MIME_TYPES, + }); const localImages = useMemo( () => diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts index ffc552f2..1c60aa92 100644 --- a/src/components/fields/types.ts +++ b/src/components/fields/types.ts @@ -6,7 +6,6 @@ import type { TableRow, TableRowRef, TableFilter, - ArrayTableRowData, } from "@src/types/table"; import type { SelectedCell } from "@src/atoms/tableScope"; import type { IContextMenuItem } from "@src/components/Table/ContextMenu/ContextMenuItem"; @@ -82,8 +81,6 @@ export interface ISideDrawerFieldProps { column: ColumnConfig; /** The row’s _rowy_ref object */ _rowy_ref: TableRowRef; - /** The array table row’s data */ - _rowy_arrayTableData?: ArrayTableRowData; /** The field’s local value – synced with db when field is not dirty */ value: T; /** Call when the user has input but changes have not been saved */ diff --git a/src/hooks/useFirestoreDocAsCollectionWithAtom.ts b/src/hooks/useFirestoreDocAsCollectionWithAtom.ts index 58affa18..76fb1a09 100644 --- a/src/hooks/useFirestoreDocAsCollectionWithAtom.ts +++ b/src/hooks/useFirestoreDocAsCollectionWithAtom.ts @@ -111,10 +111,13 @@ export function useFirestoreDocAsCollectionWithAtom( const pseudoRow = pseudoDoc.map((row: any, i: number) => { return { ...row, - _rowy_ref: docSnapshot.ref, - _rowy_arrayTableData: { - index: i, - parentField: fieldName, + _rowy_ref: { + path: docSnapshot.ref.path, + id: docSnapshot.ref.id, + arrayTableData: { + index: i, + parentField: fieldName, + }, }, }; }); @@ -188,7 +191,7 @@ export function useFirestoreDocAsCollectionWithAtom( temp.splice(options.index, 1); for (let i = options.index; i < temp.length; i++) { // @ts-ignore - temp[i]._rowy_arrayTableData.index = i; + temp[i]._rowy_ref.arrayTableData.index = i; } return sortRows(temp, sorts); }); @@ -263,10 +266,10 @@ export function useFirestoreDocAsCollectionWithAtom( _rowy_ref: { id: doc(firebaseDb, path).id, path: doc(firebaseDb, path).path, - }, - _rowy_arrayTableData: { - index: i, - parentField: fieldName, + arrayTableData: { + index: i, + parentField: fieldName, + }, }, } as T); @@ -279,8 +282,12 @@ export function useFirestoreDocAsCollectionWithAtom( const modifiedPrevData = temp.map((row: any, i: number) => { return { ...row, - _rowy_arrayTableData: { - index: i + 1, + _rowy_ref: { + ...row._rowy_ref, + arrayTableData: { + index: i + 1, + parentField: fieldName, + }, }, }; }); @@ -353,5 +360,5 @@ function sortRows( } function unsortRows(rows: T[]): T[] { - return orderBy(rows, ["_rowy_arrayTableData.index"], ["asc"]); + return orderBy(rows, ["_rowy_ref.arrayTableData.index"], ["asc"]); } diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 7b8787fd..5a0281dc 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -207,25 +207,27 @@ export type TableSort = { direction: Parameters[1]; }; +export type ArrayTableRowData = { + index: number; + parentField?: string; + operation?: ArrayTableOperations; +}; + export type TableRowRef = { id: string; path: string; + arrayTableData?: ArrayTableRowData; } & Partial; type ArrayTableOperations = { addRow?: "top" | "bottom"; base?: TableRow; }; -export type ArrayTableRowData = { - index: number; - parentField?: string; - operation?: ArrayTableOperations; -}; + export type TableRow = DocumentData & { _rowy_ref: TableRowRef; _rowy_missingRequiredFields?: string[]; _rowy_outOfOrder?: boolean; - _rowy_arrayTableData?: ArrayTableRowData; }; export type FileValue = { diff --git a/src/utils/table.ts b/src/utils/table.ts index 7a143a8b..691dd385 100644 --- a/src/utils/table.ts +++ b/src/utils/table.ts @@ -51,7 +51,6 @@ export const omitRowyFields = >(row: T) => { delete shallowClonedRow["_rowy_outOfOrder"]; delete shallowClonedRow["_rowy_missingRequiredFields"]; delete shallowClonedRow["_rowy_new"]; - delete shallowClonedRow["_rowy_arrayTableData"]; return shallowClonedRow as T; }; From e802db5725900de8290f30dbe00990fb1ceb24b5 Mon Sep 17 00:00:00 2001 From: Anish Roy <6275anishroy@gmail.com> Date: Fri, 14 Apr 2023 18:42:43 +0530 Subject: [PATCH 4/8] removed key errors --- src/components/Table/TableBody.tsx | 2 +- src/components/Table/TableHeader.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 3292ceab..b50fcdf6 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -83,7 +83,7 @@ export const TableBody = memo(function TableBody({ return ( - {headerGroups.map((headerGroup) => ( - + {headerGroups.map((headerGroup, _i) => ( + {(provided) => ( Date: Mon, 17 Apr 2023 21:25:10 +0530 Subject: [PATCH 5/8] fixed default values --- src/components/TableToolbar/AddRow.tsx | 15 ++++++++++++++- src/hooks/useFirestoreDocAsCollectionWithAtom.ts | 10 +++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/TableToolbar/AddRow.tsx b/src/components/TableToolbar/AddRow.tsx index 13e24cd5..ebeb51c4 100644 --- a/src/components/TableToolbar/AddRow.tsx +++ b/src/components/TableToolbar/AddRow.tsx @@ -28,6 +28,7 @@ import { tableSortsAtom, addRowAtom, _updateRowDbAtom, + tableColumnsOrderedAtom, } from "@src/atoms/tableScope"; export default function AddRow() { @@ -215,10 +216,22 @@ export function AddRowArraySubTable() { const anchorEl = useRef(null); const [addRowAt, setAddNewRowAt] = useState<"top" | "bottom">("bottom"); + const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + if (!updateRowDb) return null; const handleClick = () => { - updateRowDb("", {}, undefined, { + const initialValues: Record = {}; + + // Set initial values based on default values + for (const column of tableColumnsOrdered) { + if (column.config?.defaultValue?.type === "static") + initialValues[column.key] = column.config.defaultValue.value!; + else if (column.config?.defaultValue?.type === "null") + initialValues[column.key] = null; + } + + updateRowDb("", initialValues, undefined, { index: 0, operation: { addRow: addRowAt, diff --git a/src/hooks/useFirestoreDocAsCollectionWithAtom.ts b/src/hooks/useFirestoreDocAsCollectionWithAtom.ts index 76fb1a09..d4a809c6 100644 --- a/src/hooks/useFirestoreDocAsCollectionWithAtom.ts +++ b/src/hooks/useFirestoreDocAsCollectionWithAtom.ts @@ -260,9 +260,9 @@ export function useFirestoreDocAsCollectionWithAtom( const addNewRow = (addTo: "top" | "bottom", base?: TableRow) => { let temp: T[] = []; - const newRow = (i: number) => - ({ - ...base, + const newRow = (i: number) => { + return { + ...(base ?? update), _rowy_ref: { id: doc(firebaseDb, path).id, path: doc(firebaseDb, path).path, @@ -271,8 +271,8 @@ export function useFirestoreDocAsCollectionWithAtom( parentField: fieldName, }, }, - } as T); - + } as T; + }; setDataAtom((prevData) => { temp = unsortRows(prevData); From a41bc5d256b8a76c178f9f0249a79b3e696fa400 Mon Sep 17 00:00:00 2001 From: Anish Roy <6275anishroy@gmail.com> Date: Tue, 18 Apr 2023 15:32:16 +0530 Subject: [PATCH 6/8] transaction [WIP] --- src/components/fields/File/useFileUpload.ts | 2 +- .../useFirestoreDocAsCollectionWithAtom.ts | 297 ++++++++++++------ 2 files changed, 202 insertions(+), 97 deletions(-) diff --git a/src/components/fields/File/useFileUpload.ts b/src/components/fields/File/useFileUpload.ts index 40f4c394..161e44c0 100644 --- a/src/components/fields/File/useFileUpload.ts +++ b/src/components/fields/File/useFileUpload.ts @@ -48,7 +48,7 @@ export default function useFileUpload( const { uploads, failures } = await upload({ docRef, fieldName: docRef.arrayTableData - ? `${docRef.arrayTableData?.parentField}/${fieldName}` + ? `${docRef.arrayTableData?.parentField}/${docRef.arrayTableData?.index}/${fieldName}` : fieldName, files, }); diff --git a/src/hooks/useFirestoreDocAsCollectionWithAtom.ts b/src/hooks/useFirestoreDocAsCollectionWithAtom.ts index d4a809c6..b9f2650c 100644 --- a/src/hooks/useFirestoreDocAsCollectionWithAtom.ts +++ b/src/hooks/useFirestoreDocAsCollectionWithAtom.ts @@ -10,8 +10,8 @@ import { refEqual, onSnapshot, FirestoreError, - setDoc, DocumentReference, + runTransaction, } from "firebase/firestore"; import { useErrorHandler } from "react-error-boundary"; @@ -24,7 +24,8 @@ import { UpdateCollectionDocFunction, } from "@src/types/table"; import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; -import { omitRowyFields } from "@src/utils/table"; + +type UpdateFunction = (rows: T[]) => T[]; /** Options for {@link useFirestoreDocWithAtom} */ interface IUseFirestoreDocWithAtomOptions { @@ -71,7 +72,16 @@ export function useFirestoreDocAsCollectionWithAtom( const [firebaseDb] = useAtom(firebaseDbAtom, projectScope); const setDataAtom = useSetAtom(dataAtom, dataScope); - + const { addRow, deleteRow, deleteField, updateTable } = useAlterArrayTable( + { + firebaseDb, + dataAtom, + dataScope, + sorts, + path, + fieldName, + } + ); const handleError = useErrorHandler(); const { enqueueSnackbar } = useSnackbar(); const setUpdateDocAtom = useSetAtom( @@ -160,15 +170,23 @@ export function useFirestoreDocAsCollectionWithAtom( ]); const setRows = useCallback( - (rows: T[]) => { - rows = rows.map((row: any, i: number) => omitRowyFields(row)); + (updateFunction: UpdateFunction) => { if (!fieldName) return; + try { - return setDoc( - doc(firebaseDb, path), - { [fieldName]: rows }, - { merge: true } - ); + return runTransaction(firebaseDb, async (transaction) => { + const docRef = doc(firebaseDb, path); + const docSnap = await transaction.get(docRef); + const rows = docSnap.data()?.[fieldName] || []; + + const updatedRows = updateFunction(rows); + + return await transaction.set( + docRef, + { [fieldName]: updatedRows }, + { merge: true } + ); + }); } catch (error) { enqueueSnackbar(`Error updating array table`, { variant: "error", @@ -183,25 +201,14 @@ export function useFirestoreDocAsCollectionWithAtom( if (deleteDocAtom) { setDeleteRowAtom(() => (_: string, options?: ArrayTableRowData) => { if (!options) return; - - const deleteRow = () => { - let temp: T[] = []; - setDataAtom((prevData) => { - temp = unsortRows(prevData); - temp.splice(options.index, 1); - for (let i = options.index; i < temp.length; i++) { - // @ts-ignore - temp[i]._rowy_ref.arrayTableData.index = i; - } - return sortRows(temp, sorts); - }); - return setRows(temp); - }; - deleteRow(); + const updateFunction = deleteRow(options.index); + return setRows(updateFunction); }); } }, [ deleteDocAtom, + deleteRow, + fieldName, firebaseDb, path, setDataAtom, @@ -215,7 +222,7 @@ export function useFirestoreDocAsCollectionWithAtom( setUpdateDocAtom( () => ( - path_: string, + _: string, update: T, deleteFields?: string[], options?: ArrayTableRowData @@ -223,80 +230,18 @@ export function useFirestoreDocAsCollectionWithAtom( if (options === undefined) return; const deleteRowFields = () => { - let temp: T[] = []; - setDataAtom((prevData) => { - temp = unsortRows(prevData); - - if (deleteFields === undefined) return prevData; - - temp[options.index] = { - ...temp[options.index], - ...deleteFields?.reduce( - (acc, field) => ({ ...acc, [field]: undefined }), - {} - ), - }; - - return sortRows(temp, sorts); - }); - - return setRows(temp); + const updateFunction = deleteField(options.index, deleteFields); + return setRows(updateFunction); }; const updateRowValues = () => { - let temp: T[] = []; - setDataAtom((prevData) => { - temp = unsortRows(prevData); - - temp[options.index] = { - ...temp[options.index], - ...update, - }; - return sortRows(temp, sorts); - }); - return setRows(temp); + const updateFunction = updateTable(options.index, update); + return setRows(updateFunction); }; - const addNewRow = (addTo: "top" | "bottom", base?: TableRow) => { - let temp: T[] = []; - - const newRow = (i: number) => { - return { - ...(base ?? update), - _rowy_ref: { - id: doc(firebaseDb, path).id, - path: doc(firebaseDb, path).path, - arrayTableData: { - index: i, - parentField: fieldName, - }, - }, - } as T; - }; - setDataAtom((prevData) => { - temp = unsortRows(prevData); - - if (addTo === "bottom") { - temp.push(newRow(prevData.length)); - } else { - const modifiedPrevData = temp.map((row: any, i: number) => { - return { - ...row, - _rowy_ref: { - ...row._rowy_ref, - arrayTableData: { - index: i + 1, - parentField: fieldName, - }, - }, - }; - }); - temp = [newRow(0), ...modifiedPrevData]; - } - return sortRows(temp, sorts); - }); - - return setRows(temp); + const addNewRow = (addTo: "top" | "bottom", base?: T) => { + const updateFunction = addRow(addTo, base ?? update); + return setRows(updateFunction); }; if (Array.isArray(deleteFields) && deleteFields.length > 0) { @@ -304,7 +249,7 @@ export function useFirestoreDocAsCollectionWithAtom( } else if (options.operation?.addRow) { return addNewRow( options.operation.addRow, - options?.operation.base + options?.operation.base as T ); } else { return updateRowValues(); @@ -313,6 +258,8 @@ export function useFirestoreDocAsCollectionWithAtom( ); } }, [ + addRow, + deleteField, fieldName, firebaseDb, path, @@ -321,11 +268,169 @@ export function useFirestoreDocAsCollectionWithAtom( setUpdateDocAtom, sorts, updateDocAtom, + updateTable, ]); } export default useFirestoreDocAsCollectionWithAtom; +function useAlterArrayTable({ + firebaseDb, + dataAtom, + dataScope, + sorts, + path, + fieldName, +}: { + firebaseDb: Firestore; + dataAtom: PrimitiveAtom; + dataScope: Parameters[1] | undefined; + sorts: TableSort[] | undefined; + path: string; + fieldName: string; +}) { + const setData = useSetAtom(dataAtom, dataScope); + + const add = useCallback( + (addTo: "top" | "bottom", base?: T): UpdateFunction => { + const newRow = (i: number, noMeta?: boolean) => { + const meta = noMeta + ? {} + : { + _rowy_ref: { + id: doc(firebaseDb, path).id, + path: doc(firebaseDb, path).path, + arrayTableData: { + index: i, + parentField: fieldName, + }, + }, + }; + return { + ...(base ?? {}), + ...meta, + } as T; + }; + + setData((prevData) => { + prevData = unsortRows(prevData); + + if (addTo === "bottom") { + prevData.push(newRow(prevData.length)); + } else { + const modifiedPrevData = prevData.map((row: any, i: number) => { + return { + ...row, + _rowy_ref: { + ...row._rowy_ref, + arrayTableData: { + index: i + 1, + parentField: fieldName, + }, + }, + }; + }); + prevData = [newRow(0), ...modifiedPrevData]; + } + return sortRows(prevData, sorts); + }); + + return (rows) => { + if (addTo === "bottom") { + rows.push(newRow(rows.length, true)); + } else { + rows = [newRow(0, true), ...rows]; + } + return rows; + }; + }, + [fieldName, firebaseDb, path, setData, sorts] + ); + + const _delete = useCallback( + (index: number): UpdateFunction => { + setData((prevData) => { + prevData = unsortRows(prevData); + prevData.splice(index, 1); + for (let i = index; i < prevData.length; i++) { + // @ts-ignore + prevData[i]._rowy_ref.arrayTableData.index = i; + } + return sortRows(prevData, sorts); + }); + return (rows) => { + rows.splice(index, 1); + return [...rows]; + }; + }, + [setData, sorts] + ); + + const deleteField = useCallback( + (index: number, deleteFields?: string[]): UpdateFunction => { + setData((prevData) => { + prevData = unsortRows(prevData); + + if (deleteFields === undefined) return prevData; + + prevData[index] = { + ...prevData[index], + ...deleteFields?.reduce( + (acc, field) => ({ ...acc, [field]: undefined }), + {} + ), + }; + + return sortRows(prevData, sorts); + }); + return (rows) => { + if (deleteFields === undefined) return rows; + + rows[index] = { + ...rows[index], + ...deleteFields?.reduce( + (acc, field) => ({ ...acc, [field]: undefined }), + {} + ), + }; + + return rows; + }; + }, + [setData, sorts] + ); + + const update = useCallback( + (index: number, update: Partial): UpdateFunction => { + setData((prevData) => { + prevData = unsortRows(prevData); + prevData[index] = { + ...prevData[index], + ...update, + }; + + return sortRows(prevData, sorts); + }); + + return (rows) => { + rows[index] = { + ...rows[index], + ...update, + }; + return rows; + }; + }, + [setData, sorts] + ); + + return { + addRow: add, + deleteRow: _delete, + deleteField: deleteField, + updateTable: update, + }; +} + /** * Create the Firestore document reference. * Put code in a function so the results can be compared by useMemoValue. From ee5de5e0b727db91e594d11d9421c93ac53e2704 Mon Sep 17 00:00:00 2001 From: Anish Roy <6275anishroy@gmail.com> Date: Tue, 18 Apr 2023 16:18:32 +0530 Subject: [PATCH 7/8] update bug fix -> transaction complete --- src/atoms/tableScope/rowActions.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/atoms/tableScope/rowActions.ts b/src/atoms/tableScope/rowActions.ts index 8a885d80..33da9dba 100644 --- a/src/atoms/tableScope/rowActions.ts +++ b/src/atoms/tableScope/rowActions.ts @@ -369,7 +369,13 @@ export const updateFieldAtom = atom( const tableRows = get(tableRowsAtom); const tableRowsLocal = get(tableRowsLocalAtom); - const row = find(tableRows, ["_rowy_ref.path", path]); + const row = find( + tableRows, + arrayTableData?.index !== undefined + ? ["_rowy_ref.arrayTableData.index", arrayTableData?.index] + : ["_rowy_ref.path", path] + ); + if (!row) throw new Error("Could not find row"); const isLocalRow = Boolean(find(tableRowsLocal, ["_rowy_ref.path", path])); From f5557f80726b057b4ca9ef818474f4b56f978d1d Mon Sep 17 00:00:00 2001 From: Anish Roy <6275anishroy@gmail.com> Date: Wed, 19 Apr 2023 19:28:01 +0530 Subject: [PATCH 8/8] worked on requested changes --- src/components/ColumnModals/FieldsDropdown.tsx | 2 +- src/components/Table/EmptyTable.tsx | 6 +++--- src/components/TableToolbar/TableToolbar.tsx | 8 ++++++-- src/pages/Table/ProvidedArraySubTablePage.tsx | 3 +-- src/pages/Table/TablePage.tsx | 5 +---- src/types/table.d.ts | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/ColumnModals/FieldsDropdown.tsx b/src/components/ColumnModals/FieldsDropdown.tsx index 2b2497e8..18c9425e 100644 --- a/src/components/ColumnModals/FieldsDropdown.tsx +++ b/src/components/ColumnModals/FieldsDropdown.tsx @@ -44,7 +44,7 @@ export default function FieldsDropdown({ const requireCloudFunctionSetup = fieldConfig.requireCloudFunction && !projectSettings.rowyRunUrl; const requireCollectionTable = - tableSettings.isNotACollection === true && + tableSettings.isCollection === false && fieldConfig.requireCollectionTable === true; return { label: fieldConfig.name, diff --git a/src/components/Table/EmptyTable.tsx b/src/components/Table/EmptyTable.tsx index 1f14e2db..2301f43e 100644 --- a/src/components/Table/EmptyTable.tsx +++ b/src/components/Table/EmptyTable.tsx @@ -34,7 +34,7 @@ export default function EmptyTable() { : false; let contents = <>; - if (!tableSettings.isNotACollection && hasData) { + if (tableSettings.isCollection !== false && hasData) { contents = ( <>
@@ -72,7 +72,7 @@ export default function EmptyTable() { Get started - {tableSettings.isNotACollection === true + {tableSettings.isCollection === false ? "There is no data in this Array Sub Table:" : "There is no data in the Firestore collection:"}
@@ -84,7 +84,7 @@ export default function EmptyTable() {
- {!tableSettings.isNotACollection && ( + {tableSettings.isCollection !== false && ( <> diff --git a/src/components/TableToolbar/TableToolbar.tsx b/src/components/TableToolbar/TableToolbar.tsx index d7d21fbe..8224c236 100644 --- a/src/components/TableToolbar/TableToolbar.tsx +++ b/src/components/TableToolbar/TableToolbar.tsx @@ -95,10 +95,14 @@ export default function TableToolbar({ }, }} > - {tableSettings.isNotACollection ? : } + {tableSettings.isCollection === false ? ( + + ) : ( + + )}
{/* Spacer */} - {tableSettings.isNotACollection ? ( + {tableSettings.isCollection === false ? (