diff --git a/src/atoms/tableScope/columns.test.ts b/src/atoms/tableScope/columnActions.test.ts similarity index 100% rename from src/atoms/tableScope/columns.test.ts rename to src/atoms/tableScope/columnActions.test.ts diff --git a/src/atoms/tableScope/columns.ts b/src/atoms/tableScope/columnActions.ts similarity index 100% rename from src/atoms/tableScope/columns.ts rename to src/atoms/tableScope/columnActions.ts diff --git a/src/atoms/tableScope/index.ts b/src/atoms/tableScope/index.ts index 29afc6ef..38c4ea03 100644 --- a/src/atoms/tableScope/index.ts +++ b/src/atoms/tableScope/index.ts @@ -2,4 +2,5 @@ export const tableScope = Symbol("tableScope"); export * from "./table"; -export * from "./columns"; +export * from "./columnActions"; +export * from "./rowActions"; diff --git a/src/atoms/tableScope/rowActions.test.ts b/src/atoms/tableScope/rowActions.test.ts new file mode 100644 index 00000000..879b8478 --- /dev/null +++ b/src/atoms/tableScope/rowActions.test.ts @@ -0,0 +1,176 @@ +import { useCallback } from "react"; +import { renderHook, act } from "@testing-library/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useAtomCallback } from "jotai/utils"; +import { find, findIndex, mergeWith, isArray } from "lodash-es"; + +import { + tableScope, + tableSettingsAtom, + tableRowsDbAtom, + tableRowsLocalAtom, + _updateRowDbAtom, + _deleteRowDbAtom, + deleteRowAtom, +} from "@src/atoms/tableScope"; +import { TableRow } from "@src/types/table"; +import { tableRowsAtom } from "./table"; + +const TEST_COLLECTION = "_testing"; + +const initRows = (initialRowsDb?: TableRow[], initialRowsLocal?: TableRow[]) => + renderHook(async () => { + const setTableSettings = useSetAtom(tableSettingsAtom, tableScope); + setTableSettings({ + id: TEST_COLLECTION, + name: TEST_COLLECTION, + collection: TEST_COLLECTION, + roles: ["ADMIN"], + section: "", + tableType: "primaryCollection", + }); + + const setRowsDb = useSetAtom(tableRowsDbAtom, tableScope); + setRowsDb([...(initialRowsDb ?? [])]); + const readRowsDb = useAtomCallback( + useCallback((get) => get(tableRowsDbAtom), []), + tableScope + ); + + const setRowsLocal = useSetAtom(tableRowsLocalAtom, tableScope); + setRowsLocal({ type: "set", rows: [...(initialRowsLocal ?? [])] }); + + const setUpdateRowDb = useSetAtom(_updateRowDbAtom, tableScope); + setUpdateRowDb(() => async (path: string, update: Partial) => { + const rows = [...(await readRowsDb())]; + const index = findIndex(rows, ["_rowy_ref.id", path]); + // Append if not found + if (index === -1) { + setRowsDb((rows) => [ + ...rows, + { ...update, _rowy_ref: { id: path, path: "TEST_COLLECTION" } }, + ]); + } else { + rows[index] = mergeWith( + rows[index], + update, + // If proeprty to be merged is array, overwrite the array entirely. + // This matches Firestore set with merge behaviour + (objValue, srcValue) => (isArray(objValue) ? srcValue : undefined) + ); + } + setRowsDb(rows); + return Promise.resolve(); + }); + + const setDeleteRowDb = useSetAtom(_deleteRowDbAtom, tableScope); + setDeleteRowDb(() => async (path: string) => { + const rows = await readRowsDb(); + const index = findIndex(rows, ["_rowy_ref.path", path]); + if (index > -1) { + rows.splice(index, 1); + setRowsDb(rows); + } + return Promise.resolve(); + }); + }); + +const GENERATED_ROWS_LENGTH = 10; +const generatedRows = new Array(GENERATED_ROWS_LENGTH) + .fill(undefined) + .map((_, i) => ({ + _rowy_ref: { id: `row${i}`, path: TEST_COLLECTION + "/row" + i }, + index: i, + })); +const generatedRowsLocal = new Array(GENERATED_ROWS_LENGTH) + .fill(undefined) + .map((_, i) => ({ + _rowy_ref: { id: `rowLocal${i}`, path: TEST_COLLECTION + "/rowLocal" + i }, + index: i, + })); + +describe("deleteRow", () => { + test("deletes a single row", async () => { + initRows(generatedRows); + const { + result: { current: deleteRow }, + } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); + expect(deleteRow).toBeDefined(); + + await act(() => deleteRow(TEST_COLLECTION + "/row2")); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH - 1); + expect(find(tableRows, ["_rowy_ref.id", "row2"])).toBeFalsy(); + }); + + test("deletes a single local row", async () => { + initRows(generatedRows, generatedRowsLocal); + const { + result: { current: deleteRow }, + } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); + expect(deleteRow).toBeDefined(); + + await act(() => deleteRow(TEST_COLLECTION + "/rowLocal2")); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH * 2 - 1); + expect(find(tableRows, ["_rowy_ref.id", "rowLocal2"])).toBeFalsy(); + }); + + test("deletes multiple rows", async () => { + initRows(generatedRows); + const { + result: { current: deleteRow }, + } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); + expect(deleteRow).toBeDefined(); + + await act(() => + deleteRow( + ["row1", "row2", "row8"].map((id) => TEST_COLLECTION + "/" + id) + ) + ); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH - 3); + expect(find(tableRows, ["_rowy_ref.id", "row1"])).toBeFalsy(); + expect(find(tableRows, ["_rowy_ref.id", "row2"])).toBeFalsy(); + expect(find(tableRows, ["_rowy_ref.id", "row8"])).toBeFalsy(); + }); + + test("deletes all rows", async () => { + initRows(generatedRows); + const { + result: { current: deleteRow }, + } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); + expect(deleteRow).toBeDefined(); + + await act(() => deleteRow(generatedRows.map((row) => row._rowy_ref.path))); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(0); + }); + + test("doesn't delete a row that doesn't exist", async () => { + initRows(generatedRows); + const { + result: { current: deleteRow }, + } = renderHook(() => useSetAtom(deleteRowAtom, tableScope)); + expect(deleteRow).toBeDefined(); + + await act(() => deleteRow("nonExistent")); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH); + }); +}); diff --git a/src/atoms/tableScope/rowActions.ts b/src/atoms/tableScope/rowActions.ts new file mode 100644 index 00000000..91cafffe --- /dev/null +++ b/src/atoms/tableScope/rowActions.ts @@ -0,0 +1,157 @@ +import { atom } from "jotai"; +import { find } from "lodash-es"; + +import { currentUserAtom } from "@src/atoms/globalScope"; +import { + auditChangeAtom, + tableSettingsAtom, + tableFiltersAtom, + tableRowsLocalAtom, + _updateRowDbAtom, + _deleteRowDbAtom, +} from "./table"; +import { tableColumnsOrderedAtom } from "./columnActions"; +import { TableRow } from "@src/types/table"; +import { rowyUser } from "@src/utils/table"; + +export interface IAddRowOptions { + row: TableRow | TableRow[]; + ignoreRequiredFields?: boolean; +} + +/** + * Adds a row or an array of rows. + * Adds to rowsDb if it has no missing required fields, + * otherwise to or rowsLocal. + * @param options - {@link IAddRowOptions} + * + * @example Basic usage: + * ``` + * const addRow = useSetAtom(addRowAtom, tableScope); + * addRow({ row: [ {...}, ... ] }); + * ``` + */ +export const addRowAtom = atom( + null, + async (get, set, { row, ignoreRequiredFields }: IAddRowOptions) => { + const updateRowDb = get(_updateRowDbAtom); + if (!updateRowDb) throw new Error("Cannot write to database"); + const tableSettings = get(tableSettingsAtom); + if (!tableSettings) throw new Error("Cannot read table settings"); + const currentUser = get(currentUserAtom); + if (!currentUser) throw new Error("Cannot read current user"); + const auditChange = get(auditChangeAtom); + const tableFilters = get(tableFiltersAtom); + const tableColumnsOrdered = get(tableColumnsOrderedAtom); + + const _addSingleRowAndAudit = async (row: TableRow) => { + // Store initial values to be written + const initialValues: TableRow = { _rowy_ref: row._rowy_ref }; + + // Store tableFilters that mean this row should be out of order + const outOfOrderFilters = new Set( + tableFilters.map((filter) => filter.key) + ); + // Set initial values based on table filters, so rowsDb will include this. + // If we can set the value for a filter key, remove that key from outOfOrderFilters + for (const filter of tableFilters) { + if (filter.operator === "==") { + initialValues[filter.key] = filter.value; + outOfOrderFilters.delete(filter.key); + } else if (filter.operator === "array-contains") { + initialValues[filter.key] = [filter.value]; + outOfOrderFilters.delete(filter.key); + } + } + + // Set initial values based on default values + for (const column of tableColumnsOrdered) { + if (column.config?.defaultValue?.type === "static") + initialValues[column.key] = column.config.defaultValue.value!; + else if (column.config?.defaultValue?.type === "null") + initialValues[column.key] = null; + } + + // Write audit fields if not explicitly disabled + if (tableSettings.audit !== false) { + const auditValue = rowyUser(currentUser); + initialValues[tableSettings.auditFieldCreatedBy || "_createdBy"] = + auditValue; + initialValues[tableSettings.auditFieldUpdatedBy || "_updatedBy"] = + auditValue; + } + + // Check for required fields + const requiredFields = ignoreRequiredFields + ? [] + : tableColumnsOrdered + .filter((column) => column.config?.required) + .map((column) => column.key); + const missingRequiredFields = ignoreRequiredFields + ? [] + : requiredFields.filter((field) => row[field] === undefined); + + // Combine initial values with row values + const rowValues = { ...initialValues, ...row }; + + // Add to rowsLocal if any required fields are missing or + // deliberately out of order + if ( + missingRequiredFields.length > 0 || + row._rowy_outOfOrder === true || + outOfOrderFilters.size > 0 + ) { + set(tableRowsLocalAtom, { + type: "add", + row: { ...rowValues, _rowy_outOfOrder: true }, + }); + } else { + await updateRowDb(row._rowy_ref.path, rowValues); + } + + if (auditChange) auditChange("ADD_ROW", row._rowy_ref.path); + }; + + if (Array.isArray(row)) { + const promises = row.map(_addSingleRowAndAudit); + await Promise.all(promises); + } else { + await _addSingleRowAndAudit(row); + } + } +); + +/** + * Deletes a row or an array of rows from rowsDb or rowsLocal. + * @param path - A single path or array of paths of rows to delete + * + * @example Basic usage: + * ``` + * const deleteRow = useSetAtom(deleteRowAtom, tableScope); + * deleteRow( path ); + * ``` + */ +export const deleteRowAtom = atom( + null, + async (get, set, path: string | string[]) => { + const deleteRowDb = get(_deleteRowDbAtom); + if (!deleteRowDb) throw new Error("Cannot write to database"); + + const auditChange = get(auditChangeAtom); + const rowsLocal = get(tableRowsLocalAtom); + + const _deleteSingleRowAndAudit = async (path: string) => { + const isLocalRow = Boolean(find(rowsLocal, ["_rowy_ref.path", path])); + if (isLocalRow) set(tableRowsLocalAtom, { type: "delete", path }); + else await deleteRowDb(path); + if (auditChange) auditChange("DELETE_ROW", path); + }; + + if (Array.isArray(path)) { + const promises = path.map(_deleteSingleRowAndAudit); + await Promise.all(promises); + } else { + await _deleteSingleRowAndAudit(path); + } + } +); diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts index b03b3f8b..f86f2519 100644 --- a/src/atoms/tableScope/table.ts +++ b/src/atoms/tableScope/table.ts @@ -1,5 +1,6 @@ import { atom } from "jotai"; -import { uniqBy } from "lodash-es"; +import { atomWithReducer } from "jotai/utils"; +import { uniqBy, findIndex } from "lodash-es"; import { TableSettings, @@ -10,6 +11,7 @@ import { UpdateCollectionDocFunction, DeleteCollectionDocFunction, } from "@src/types/table"; +import { updateRowData } from "@src/utils/table"; /** Root atom from which others are derived */ export const tableIdAtom = atom(undefined); @@ -29,8 +31,66 @@ export const tableOrdersAtom = atom([]); /** Latest page in the infinite scroll */ export const tablePageAtom = atom(0); -/** Store rows that are out of order or not ready to be written to the db */ -export const tableRowsLocalAtom = atom([]); +type TableRowsLocalAction = + /** Overwrite all rows */ + | { type: "set"; rows: TableRow[] } + /** Add a row or multiple rows */ + | { type: "add"; row: TableRow | TableRow[] } + /** Update a row */ + | { type: "update"; path: string; row: Partial } + /** Delete a row or multiple rows */ + | { type: "delete"; path: string | string[] }; +const tableRowsLocalReducer = ( + prev: TableRow[], + action: TableRowsLocalAction +): TableRow[] => { + if (action.type === "set") { + return [...action.rows]; + } + if (action.type === "add") { + if (Array.isArray(action.row)) return [...action.row, ...prev]; + return [action.row, ...prev]; + } + if (action.type === "update") { + const index = findIndex(prev, ["_rowy_ref.path", action.path]); + if (index > -1) { + const updatedRows = [...prev]; + updatedRows[index] = updateRowData(prev[index], action.row); + return updatedRows; + } + // If not found, add to start + if (index === -1) + return [ + { + ...action.row, + _rowy_ref: { + path: action.path, + id: action.path.split("/").pop() || action.path, + }, + }, + ...prev, + ]; + } + if (action.type === "delete") { + return prev.filter((row) => { + if (Array.isArray(action.path)) { + return !action.path.includes(row._rowy_ref.path); + } else { + return row._rowy_ref.path !== action.path; + } + }); + } + throw new Error("Invalid action"); +}; +/** + * Store rows that are out of order or not ready to be written to the db. + * See {@link TableRowsLocalAction} for reducer actions. + */ +export const tableRowsLocalAtom = atomWithReducer( + [] as TableRow[], + tableRowsLocalReducer +); + /** Store rows from the db listener */ export const tableRowsDbAtom = atom([]); /** Combine tableRowsLocal and tableRowsDb */ @@ -46,7 +106,9 @@ export const tableLoadingMoreAtom = atom(false); /** * Store function to add or update row in db directly. * Has same behaviour as Firestore setDoc with merge. - * See https://stackoverflow.com/a/47554197/3572007 + * @see + * - {@link updateRowData} implementation + * - https://stackoverflow.com/a/47554197/3572007 * @internal Use {@link addRowAtom} or {@link updateRowAtom} instead */ export const _updateRowDbAtom = atom( diff --git a/src/pages/TableTest.tsx b/src/pages/TableTest.tsx index 9fd539b8..c5214076 100644 --- a/src/pages/TableTest.tsx +++ b/src/pages/TableTest.tsx @@ -18,7 +18,7 @@ import TableHeaderSkeleton from "@src/components/Table/Skeleton/TableHeaderSkele import HeaderRowSkeleton from "@src/components/Table/Skeleton/HeaderRowSkeleton"; import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; -import { globalScope } from "@src/atoms/globalScope"; +import { currentUserAtom, globalScope } from "@src/atoms/globalScope"; import { doc, setDoc, updateDoc } from "firebase/firestore"; import { TABLE_SCHEMAS } from "@src/config/dbPaths"; @@ -105,6 +105,7 @@ function TableTestPage() { export default function ProvidedTableTestPage() { const { id } = useParams(); + const [currentUser] = useAtom(currentUserAtom, globalScope); return ( } > - + diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 62d8c171..a335c8dd 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -65,18 +65,28 @@ export type ColumnConfig = { editable?: boolean; /** Column-specific config */ - config: { [key: string]: any }; + config?: { + required?: boolean; + defaultValue?: { + type: "undefined" | "null" | "static" | "dynamic"; + value?: any; + script?: string; + dynamicValueFn?: string; + }; + + [key: string]: any; + }; // [key: string]: any; }; export type TableFilter = { - key: Parameters[0]; + key: string; operator: Parameters[1]; - value: Parameters[2]; + value: any; }; export type TableOrder = { - key: Parameters[0]; + key: string; direction: Parameters[1]; }; diff --git a/src/utils/table.ts b/src/utils/table.ts new file mode 100644 index 00000000..4c5712ae --- /dev/null +++ b/src/utils/table.ts @@ -0,0 +1,41 @@ +import { mergeWith, isArray } from "lodash-es"; +import type { User } from "firebase/auth"; +import { TableRow } from "@src/types/table"; + +/** + * Creates a standard user object to write to table rows + * @param currentUser - The current signed-in user + * @param data - Any additional data to include + * @returns rowyUser object + */ +export const rowyUser = (currentUser: User, data?: Record) => { + const { displayName, email, uid, emailVerified, isAnonymous, photoURL } = + currentUser; + + return { + timestamp: new Date(), + displayName, + email, + uid, + emailVerified, + isAnonymous, + photoURL, + ...data, + }; +}; + +/** + * Updates row data with the same behavior as Firestore’s setDoc with merge. + * Merges objects recursively, but overwrites arrays. + * @see https://stackoverflow.com/a/47554197/3572007 + * @param row - The source row to update + * @param update - The partial update to apply + * @returns The row with updated values + */ +export const updateRowData = (row: TableRow, update: Partial) => + mergeWith( + row, + update, + // If the proeprty to be merged is array, overwrite the array entirely + (objValue, srcValue) => (isArray(objValue) ? srcValue : undefined) + );