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:
+
- (
- }
- onClick={onClick}
- >
- Import data
-
- )}
- PopoverProps={{
- anchorOrigin: {
- vertical: "bottom",
- horizontal: "center",
- },
- transformOrigin: {
- vertical: "top",
- horizontal: "center",
- },
- }}
- />
-
+ (
+ }
+ onClick={onClick}
+ >
+ Import data
+
+ )}
+ 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 (
+ <>
+
+ : }
+ >
+ Add row to {addRowAt}
+
+
+
+
+
+
+ >
+ );
+}
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 ? (
+ }
+ disabled={true}
+ >
+ Filter
+
+ ) : (
+ }>
+
+
+ )}
{/* 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;
};