worked on array subtable

This commit is contained in:
Anish Roy
2023-04-12 17:43:07 +05:30
parent 41d8feb84b
commit da0cf161df
46 changed files with 1450 additions and 201 deletions

View File

@@ -0,0 +1,9 @@
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
export function ArraySubTable(props: SvgIconProps) {
return (
<SvgIcon {...props}>
<path d="M1 4C1 2.34315 2.34315 1 4 1H18C19.6569 1 21 2.34315 21 4V11H19H12V15V17H4C2.34315 17 1 15.6569 1 14V4ZM10 15V11H3V14C3 14.5523 3.44772 15 4 15H10ZM12 9H19V5H12V9ZM10 5H3V9H10V5ZM15 13H14V14V22V23H15H17V21H16V15H17V13H15ZM21 13H22V14V22V23H21H19V21H20V15H19V13H21Z" />
</SvgIcon>
);
}

View File

@@ -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 },

View File

@@ -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
);
}

View File

@@ -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<SelectedCell | null>(null);

View File

@@ -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)}
</ListItemIcon>
<Typography>{option.label}</Typography>
{option.requireCloudFunctionSetup && (
{option.requireCollectionTable ? (
<Typography
color="error"
variant="inherit"
component="span"
marginLeft={1}
className={"require-cloud-function"}
>
{" "}
Unavailable
</Typography>
) : option.requireCloudFunctionSetup ? (
<Typography
color="error"
variant="inherit"
@@ -107,7 +124,7 @@ export default function FieldsDropdown({
Cloud Function
</span>
</Typography>
)}
) : null}
</>
)}
label={label || "Field type"}

View File

@@ -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({
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={<FieldSkeleton />}>
{children ??
(!debugText && (
(!debugValue && (
<Typography
variant="body2"
color="text.secondary"
@@ -112,7 +114,7 @@ export default function FieldWrapper({
</Suspense>
</ErrorBoundary>
{debugText && (
{debugValue && (
<Stack direction="row" alignItems="center">
<Typography
variant="body2"
@@ -131,7 +133,7 @@ export default function FieldWrapper({
</Typography>
<IconButton
onClick={() => {
copyToClipboard(debugText as string);
copyToClipboard(debugValue as string);
enqueueSnackbar("Copied!");
}}
>
@@ -139,7 +141,7 @@ export default function FieldWrapper({
</IconButton>
<IconButton
href={`https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${(
debugText as string
debugValue as string
).replace(/\//g, "~2F")}`}
target="_blank"
rel="noopener"

View File

@@ -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 { ColumnConfig, TableRowRef } from "@src/types/table";
import { ArrayTableRowData, ColumnConfig, TableRowRef } from "@src/types/table";
export interface IMemoizedFieldProps {
field: ColumnConfig;
@@ -13,6 +13,7 @@ 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;
@@ -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,
})}
</FieldWrapper>
);

View File

@@ -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;

View File

@@ -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 && (

View File

@@ -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:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
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:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
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:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Duplicate",
handleConfirm: handleDuplicate,
});
onClose();
},
onClick: handleDuplicate,
},
{
label: altPress ? "Delete" : "Delete…",
color: "error",
icon: <DeleteIcon />,
disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly,
onClick: altPress
? handleDelete
: () => {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row._rowy_ref.path}
</code>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDelete,
});
onClose();
},
onClick: handleDelete,
},
];

View File

@@ -34,7 +34,7 @@ export default function EmptyTable() {
: false;
let contents = <></>;
if (hasData) {
if (!tableSettings.isNotACollection && hasData) {
contents = (
<>
<div>
@@ -72,47 +72,56 @@ export default function EmptyTable() {
Get started
</Typography>
<Typography>
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:"}
<br />
<code>{tableSettings.collection}</code>
<code>
{tableSettings.collection}
{tableSettings.subTableKey?.length &&
`.${tableSettings.subTableKey}`}
</code>
</Typography>
</div>
<Grid container spacing={1}>
<Grid item xs>
<Typography paragraph>
You can import data from an external source:
</Typography>
{!tableSettings.isNotACollection && (
<>
<Grid item xs>
<Typography paragraph>
You can import data from an external source:
</Typography>
<ImportData
render={(onClick) => (
<Button
variant="contained"
color="primary"
startIcon={<ImportIcon />}
onClick={onClick}
>
Import data
</Button>
)}
PopoverProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "center",
},
transformOrigin: {
vertical: "top",
horizontal: "center",
},
}}
/>
</Grid>
<ImportData
render={(onClick) => (
<Button
variant="contained"
color="primary"
startIcon={<ImportIcon />}
onClick={onClick}
>
Import data
</Button>
)}
PopoverProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "center",
},
transformOrigin: {
vertical: "top",
horizontal: "center",
},
}}
/>
</Grid>
<Grid item>
<Divider orientation="vertical">
<Typography variant="overline">or</Typography>
</Divider>
</Grid>
<Grid item>
<Divider orientation="vertical">
<Typography variant="overline">or</Typography>
</Divider>
</Grid>
</>
)}
<Grid item xs>
<Typography paragraph>

View File

@@ -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:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row.original._rowy_ref.path}
</code>
</>
),
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:
<br />
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{row.original._rowy_ref.path}
</code>
</>
),
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:
<br />
<code
style={{ userSelect: "all", wordBreak: "break-all" }}
>
{row.original._rowy_ref.path}
</code>
</>
),
confirm: "Duplicate",
handleConfirm: handleDuplicate,
});
}
}
onClick={handleDuplicate}
className="row-hover-iconButton"
tabIndex={focusInsideCell ? 0 : -1}
>
@@ -106,29 +145,7 @@ export const FinalColumn = memo(function FinalColumn({
<IconButton
size="small"
color="inherit"
onClick={
altPress
? handleDelete
: () => {
confirm({
title: "Delete row?",
body: (
<>
Row path:
<br />
<code
style={{ userSelect: "all", wordBreak: "break-all" }}
>
{row.original._rowy_ref.path}
</code>
</>
),
confirm: "Delete",
confirmColor: "error",
handleConfirm: handleDelete,
});
}
}
onClick={handleDelete}
className="row-hover-iconButton"
tabIndex={focusInsideCell ? 0 : -1}
sx={{

View File

@@ -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",

View File

@@ -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" });

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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<HTMLDivElement>(null);
const [addRowAt, setAddNewRowAt] = useState<"top" | "bottom">("bottom");
if (!updateRowDb) return null;
const handleClick = () => {
updateRowDb("", {}, undefined, {
index: 0,
operation: {
addRow: addRowAt,
},
});
};
return (
<>
<ButtonGroup
variant="contained"
color="primary"
aria-label="Split button"
ref={anchorEl}
>
<Button
variant="contained"
color="primary"
onClick={handleClick}
startIcon={addRowAt === "top" ? <AddRowTopIcon /> : <AddRowIcon />}
>
Add row to {addRowAt}
</Button>
<Button
variant="contained"
color="primary"
aria-label="Select row add position"
aria-haspopup="menu"
style={{ padding: 0 }}
onClick={() => setOpen(true)}
id="add-row-menu-button"
aria-controls={open ? "add-row-menu" : undefined}
aria-expanded={open ? "true" : "false"}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Select
id="add-row-menu"
open={open}
onClose={() => setOpen(false)}
label="Row add position"
style={{ display: "none" }}
value={addRowAt}
onChange={(e) => setAddNewRowAt(e.target.value as typeof addRowAt)}
MenuProps={{
anchorEl: anchorEl.current,
MenuListProps: { "aria-labelledby": "add-row-menu-button" },
anchorOrigin: { horizontal: "left", vertical: "bottom" },
transformOrigin: { horizontal: "left", vertical: "top" },
}}
>
<MenuItem value="top">
<ListItemText
primary="To top"
secondary="Adds a new row to the top of this table"
secondaryTypographyProps={{ variant: "caption" }}
/>
</MenuItem>
<MenuItem value="bottom">
<ListItemText
primary="To bottom"
secondary={"Adds a new row to the bottom of this table"}
secondaryTypographyProps={{
variant: "caption",
whiteSpace: "pre-line",
}}
/>
</MenuItem>
</Select>
</>
);
}

View File

@@ -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 (
<Stack
direction="row"
@@ -87,27 +95,47 @@ export default function TableToolbar() {
},
}}
>
<AddRow />
{tableSettings.isNotACollection ? <AddRowArraySubTable /> : <AddRow />}
<div /> {/* Spacer */}
<HiddenFields />
<Suspense fallback={<ButtonSkeleton />}>
<Filters />
</Suspense>
{tableSettings.isNotACollection ? (
<Button
variant="outlined"
color="primary"
startIcon={<FilterIcon />}
disabled={true}
>
Filter
</Button>
) : (
<Suspense fallback={<ButtonSkeleton />}>
<Filters />
</Suspense>
)}
<div /> {/* Spacer */}
<LoadedRowsStatus />
<div style={{ flexGrow: 1, minWidth: 64 }} />
<RowHeight />
<div /> {/* Spacer */}
{tableSettings.tableType !== "collectionGroup" && (
<Suspense fallback={<ButtonSkeleton />}>
<ImportData />
</Suspense>
{disabledTools.includes("import") ? (
<TableToolbarButton
title="Import data"
icon={<ImportIcon />}
disabled={true}
/>
) : (
tableSettings.tableType !== "collectionGroup" && (
<Suspense fallback={<ButtonSkeleton />}>
<ImportData />
</Suspense>
)
)}
<Suspense fallback={<ButtonSkeleton />}>
<TableToolbarButton
title="Export/Download"
onClick={() => openTableModal("export")}
icon={<ExportIcon />}
disabled={disabledTools.includes("export")}
/>
</Suspense>
{userRoles.includes("ADMIN") && (
@@ -123,6 +151,7 @@ export default function TableToolbar() {
}
}}
icon={<WebhookIcon />}
disabled={disabledTools.includes("webhooks")}
/>
<TableToolbarButton
title="Extensions"
@@ -131,6 +160,7 @@ export default function TableToolbar() {
else openRowyRunModal({ feature: "Extensions" });
}}
icon={<ExtensionIcon />}
disabled={disabledTools.includes("extensions")}
/>
<TableToolbarButton
title="Cloud logs"
@@ -139,6 +169,7 @@ export default function TableToolbar() {
if (projectSettings.rowyRunUrl) openTableModal("cloudLogs");
else openRowyRunModal({ feature: "Cloud logs" });
}}
disabled={disabledTools.includes("cloud_logs")}
/>
{(hasDerivatives || hasExtensions) && (
<Suspense fallback={<ButtonSkeleton />}>

View File

@@ -31,6 +31,7 @@ export const config: IFieldConfig = {
settings: Settings,
requireConfiguration: true,
requireCloudFunction: true,
requireCollectionTable: true,
sortKey: "status",
};
export default config;

View File

@@ -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 (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
style={{ paddingLeft: "var(--cell-padding)", width: "100%" }}
>
<div style={{ flexGrow: 1, overflow: "hidden" }}>
{documentCount} {column.name as string}: {label}
</div>
<IconButton
component={Link}
to={subTablePath}
className="row-hover-iconButton end"
size="small"
disabled={!subTablePath}
tabIndex={tabIndex}
>
<OpenIcon />
</IconButton>
</Stack>
);
}

View File

@@ -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 (
<MultiSelect
label="Parent label"
options={columnOptions}
value={config.parentLabel ?? []}
onChange={onChange("parentLabel")}
/>
);
};
export default Settings;

View File

@@ -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 (
<Stack direction="row" id={getFieldId(column.key)}>
<Box sx={fieldSx}>
{documentCount} {column.name as string}: {label}
</Box>
<IconButton
component={Link}
to={subTablePath}
edge="end"
size="small"
sx={{ ml: 1 }}
disabled={!subTablePath}
>
<OpenIcon />
</IconButton>
</Stack>
);
}

View File

@@ -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: <ArraySubTableIcon />,
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;

View File

@@ -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 };
};

View File

@@ -27,5 +27,6 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
settings: Settings,
requireCollectionTable: true,
};
export default config;

View File

@@ -28,5 +28,6 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
settings: Settings,
requireCollectionTable: true,
};
export default config;

View File

@@ -22,5 +22,6 @@ export const config: IFieldConfig = {
settingsValidator,
requireConfiguration: true,
requireCloudFunction: true,
requireCollectionTable: true,
};
export default config;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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(
() =>

View File

@@ -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(
() =>

View File

@@ -31,5 +31,6 @@ export const config: IFieldConfig = {
SideDrawerField,
initializable: false,
requireConfiguration: true,
requireCollectionTable: true,
};
export default config;

View File

@@ -28,5 +28,6 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
settings: Settings,
requireCollectionTable: true,
};
export default config;

View File

@@ -29,5 +29,6 @@ export const config: IFieldConfig = {
TableCell: withRenderTableCell(DisplayCell, null),
SideDrawerField,
settings: Settings,
requireCollectionTable: true,
};
export default config;

View File

@@ -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,

View File

@@ -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<T = any> {
column: ColumnConfig;
/** The rows _rowy_ref object */
_rowy_ref: TableRowRef;
/** The array table rows data */
_rowy_arrayTableData?: ArrayTableRowData;
/** The fields local value  synced with db when field is not dirty */
value: T;
/** Call when the user has input but changes have not been saved */

View File

@@ -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",

View File

@@ -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 */

View File

@@ -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<T> {
/** 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 doesnt exist with the following data */
createIfNonExistent?: T;
/** Set this atoms value to a function that updates the document. Uses same scope as `dataScope`. */
// updateDataAtom?: PrimitiveAtom<UpdateDocFunction<T> | undefined>;
updateDocAtom?: PrimitiveAtom<UpdateCollectionDocFunction<T> | undefined>;
deleteDocAtom?: PrimitiveAtom<DeleteCollectionDocFunction | undefined>;
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 isnt created at all.
* @param fieldName - Parent field name
* @param options - {@link IUseFirestoreDocWithAtomOptions}
*/
export function useFirestoreDocAsCollectionWithAtom<T = TableRow>(
dataAtom: PrimitiveAtom<T[]>,
dataScope: Parameters<typeof useAtom>[1] | undefined,
path: string,
fieldName: string,
options: IUseFirestoreDocWithAtomOptions<T>
) {
// 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 Firestores refEqual
const memoizedDocRef = useMemoValue(
getDocRef<T>(firebaseDb, path),
(next, prev) => refEqual(next as any, prev as any)
);
useEffect(() => {
// If path is invalid and no memoizedDocRef was created, dont 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<T>(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<T>(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 = <T>(
firebaseDb: Firestore,
path: string | undefined,
pathSegments?: Array<string | undefined>
) => {
if (!path || (Array.isArray(pathSegments) && pathSegments?.some((x) => !x)))
return null;
return doc(
firebaseDb,
path,
...((pathSegments as string[]) || [])
) as DocumentReference<T>;
};
function sortRows<T = TableRow>(
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<T = TableRow>(rows: T[]): T[] {
return orderBy(rows, ["_rowy_arrayTableData.index"], ["asc"]);
}

View File

@@ -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 cant 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 (
<Modal
title={
<BreadcrumbsSubTable
rootTableSettings={rootTableSettings}
subTableSettings={subTableSettings}
rootTableLink={rootTableLink}
/>
}
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" }}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense
fallback={
<>
<TableToolbarSkeleton />
<TableSkeleton />
</>
}
>
<Provider
key={tableScope.description + "/subTable/" + subTableSettings.id}
scope={tableScope}
initialValues={[
[currentUserAtom, currentUser],
[tableIdAtom, subTableSettings.id],
[tableSettingsAtom, subTableSettings],
]}
>
<DebugAtoms scope={tableScope} />
<ArraySubTableSourceFirestore />
<TablePage
tableNotACollection={true}
disabledTools={[
"import",
"export",
"webhooks",
"extensions",
"cloud_logs",
]}
/>
</Provider>
</Suspense>
</ErrorBoundary>
</Modal>
);
}

View File

@@ -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({
<ActionParamsProvider>
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={<TableToolbarSkeleton />}>
<TableToolbar />
<TableToolbar disabledTools={disabledTools} />
</Suspense>
</ErrorBoundary>

View File

@@ -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<TableSchema>(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 doesnt 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<TableRow>(
tableRowsDbAtom,
tableScope,
tableSettings.collection,
tableSettings.subTableKey || "",
{
sorts,
onError: handleErrorCallback,
updateDocAtom: _updateRowDbAtom,
deleteDocAtom: _deleteRowDbAtom,
}
);
useAuditChange();
useBulkWriteDb();
return null;
});
export default TableSourceFirestore2;

29
src/types/table.d.ts vendored
View File

@@ -31,7 +31,8 @@ export type UpdateDocFunction<T = TableRow> = (
export type UpdateCollectionDocFunction<T = TableRow> = (
path: string,
update: Partial<T>,
deleteFields?: string[]
deleteFields?: string[],
options?: ArrayTableRowData
) => Promise<void>;
/**
@@ -39,7 +40,10 @@ export type UpdateCollectionDocFunction<T = TableRow> = (
* @param path - The full path to the doc
* @returns Promise
*/
export type DeleteCollectionDocFunction = (path: string) => Promise<void>;
export type DeleteCollectionDocFunction = (
path: string,
options?: ArrayTableRowData
) => Promise<void>;
export type BulkWriteOperation<T> =
| { 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<typeof orderBy>[1];
@@ -197,10 +212,20 @@ export type TableRowRef = {
path: string;
} & Partial<DocumentReference>;
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 = {

View File

@@ -51,6 +51,7 @@ export const omitRowyFields = <T = Record<string, any>>(row: T) => {
delete shallowClonedRow["_rowy_outOfOrder"];
delete shallowClonedRow["_rowy_missingRequiredFields"];
delete shallowClonedRow["_rowy_new"];
delete shallowClonedRow["_rowy_arrayTableData"];
return shallowClonedRow as T;
};