From daddc308618f0d04eec1bc73676a90f31cfd5722 Mon Sep 17 00:00:00 2001 From: Sidney Alcantara Date: Tue, 17 May 2022 19:16:31 +1000 Subject: [PATCH] add updateFieldAtom & id decrement --- src/atoms/tableScope/columnActions.test.ts | 18 ++- src/atoms/tableScope/columnActions.ts | 2 +- src/atoms/tableScope/rowActions.test.ts | 171 ++++++++++++++++++-- src/atoms/tableScope/rowActions.ts | 168 +++++++++++++++++-- src/atoms/tableScope/table.ts | 22 ++- src/hooks/useFirestoreCollectionWithAtom.ts | 15 +- src/hooks/useFirestoreDocWithAtom.ts | 16 +- src/pages/TableTest.tsx | 2 +- src/sources/TableSourceFirestore.tsx | 7 +- src/types/table.d.ts | 24 ++- src/utils/table.ts | 58 ++++++- 11 files changed, 462 insertions(+), 41 deletions(-) diff --git a/src/atoms/tableScope/columnActions.test.ts b/src/atoms/tableScope/columnActions.test.ts index d9b68965..361b152e 100644 --- a/src/atoms/tableScope/columnActions.test.ts +++ b/src/atoms/tableScope/columnActions.test.ts @@ -1,5 +1,6 @@ import { renderHook, act } from "@testing-library/react"; import { useAtomValue, useSetAtom } from "jotai"; +import { cloneDeep, unset } from "lodash-es"; import { tableScope, @@ -11,6 +12,7 @@ import { } from "@src/atoms/tableScope"; import { TableSchema } from "@src/types/table"; import { FieldType } from "@src/constants/fields"; +import { updateRowData } from "@src/utils/table"; const initUpdateTableSchemaAtom = (initialTableSchema?: TableSchema) => renderHook(() => { @@ -18,9 +20,19 @@ const initUpdateTableSchemaAtom = (initialTableSchema?: TableSchema) => setTableSchema(initialTableSchema ?? {}); const setUpdateTableSchema = useSetAtom(updateTableSchemaAtom, tableScope); - setUpdateTableSchema(() => async (update: Partial) => { - setTableSchema(update); - }); + setUpdateTableSchema( + () => async (update: Partial, deleteFields?: string[]) => { + setTableSchema((current) => { + const withFieldsDeleted = cloneDeep(current); + if (Array.isArray(deleteFields)) { + for (const field of deleteFields) { + unset(withFieldsDeleted, field); + } + } + return updateRowData(withFieldsDeleted || {}, update); + }); + } + ); }); const GENERATED_COLUMNS_LENGTH = 10; diff --git a/src/atoms/tableScope/columnActions.ts b/src/atoms/tableScope/columnActions.ts index 3a5c3ffe..8a25456c 100644 --- a/src/atoms/tableScope/columnActions.ts +++ b/src/atoms/tableScope/columnActions.ts @@ -130,5 +130,5 @@ export const deleteColumnAtom = atom(null, async (get, _set, key: string) => { .filter((c) => c.key !== key) .reduce(tableColumnsReducer, {}); - await updateTableSchema({ columns: updatedColumns }); + await updateTableSchema({ columns: updatedColumns }, [`columns.${key}`]); }); diff --git a/src/atoms/tableScope/rowActions.test.ts b/src/atoms/tableScope/rowActions.test.ts index 879b8478..2db6841b 100644 --- a/src/atoms/tableScope/rowActions.test.ts +++ b/src/atoms/tableScope/rowActions.test.ts @@ -4,6 +4,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useAtomCallback } from "jotai/utils"; import { find, findIndex, mergeWith, isArray } from "lodash-es"; +import { currentUserAtom } from "@src/atoms/globalScope"; import { tableScope, tableSettingsAtom, @@ -11,15 +12,24 @@ import { tableRowsLocalAtom, _updateRowDbAtom, _deleteRowDbAtom, + addRowAtom, deleteRowAtom, } from "@src/atoms/tableScope"; import { TableRow } from "@src/types/table"; import { tableRowsAtom } from "./table"; +import { updateRowData, decrementId } from "@src/utils/table"; const TEST_COLLECTION = "_testing"; const initRows = (initialRowsDb?: TableRow[], initialRowsLocal?: TableRow[]) => renderHook(async () => { + const setCurrentUser = useSetAtom(currentUserAtom, tableScope); + setCurrentUser({ + uid: "TEST_USER", + displayName: "Test User", + email: "test@example.com", + } as any); + const setTableSettings = useSetAtom(tableSettingsAtom, tableScope); setTableSettings({ id: TEST_COLLECTION, @@ -43,23 +53,17 @@ const initRows = (initialRowsDb?: TableRow[], initialRowsLocal?: TableRow[]) => const setUpdateRowDb = useSetAtom(_updateRowDbAtom, tableScope); setUpdateRowDb(() => async (path: string, update: Partial) => { const rows = [...(await readRowsDb())]; - const index = findIndex(rows, ["_rowy_ref.id", path]); + const index = findIndex(rows, ["_rowy_ref.path", path]); // Append if not found if (index === -1) { - setRowsDb((rows) => [ + setRowsDb([ ...rows, - { ...update, _rowy_ref: { id: path, path: "TEST_COLLECTION" } }, + { ...update, _rowy_ref: { id: path.split("/").pop()!, path } }, ]); } 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) - ); + rows[index] = updateRowData(rows[index], update); + setRowsDb(rows); } - setRowsDb(rows); return Promise.resolve(); }); @@ -89,6 +93,136 @@ const generatedRowsLocal = new Array(GENERATED_ROWS_LENGTH) index: i, })); +describe("addRow", () => { + test("adds a single row with pre-defined id", async () => { + initRows(generatedRows); + const { + result: { current: addRow }, + } = renderHook(() => useSetAtom(addRowAtom, tableScope)); + expect(addRow).toBeDefined(); + + await act(() => + addRow({ + row: { + _rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" }, + added: true, + }, + }) + ); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH + 1); + expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeDefined(); + expect(find(tableRows, ["_rowy_ref.id", "addedRow"])?.added).toBe(true); + }); + + test("adds a single row with pre-defined id to an empty table", async () => { + initRows(); + const { + result: { current: addRow }, + } = renderHook(() => useSetAtom(addRowAtom, tableScope)); + expect(addRow).toBeDefined(); + + await act(() => + addRow({ + row: { + _rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" }, + added: true, + }, + }) + ); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(1); + expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeDefined(); + expect(find(tableRows, ["_rowy_ref.id", "addedRow"])?.added).toBe(true); + }); + + test("adds a single row and generate random id", async () => { + initRows(generatedRows); + const { + result: { current: addRow }, + } = renderHook(() => useSetAtom(addRowAtom, tableScope)); + expect(addRow).toBeDefined(); + + await act(() => + addRow({ + row: { + _rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" }, + added: true, + }, + setId: "random", + }) + ); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH + 1); + expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeUndefined(); + expect(find(tableRows, ["added", true])).toBeDefined(); + expect(find(tableRows, ["added", true])?._rowy_ref.id).toHaveLength(20); + }); + + test("adds a single row and decrement id", async () => { + initRows(generatedRows); + const { + result: { current: addRow }, + } = renderHook(() => useSetAtom(addRowAtom, tableScope)); + expect(addRow).toBeDefined(); + + await act(() => + addRow({ + row: { + _rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" }, + added: true, + }, + setId: "decrement", + }) + ); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH + 1); + expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeUndefined(); + expect(find(tableRows, ["added", true])).toBeDefined(); + expect(find(tableRows, ["added", true])?._rowy_ref.id).toBe( + decrementId("row0") + ); + }); + + test("adds a single row with decrement id to an empty table", async () => { + initRows([]); + const { + result: { current: addRow }, + } = renderHook(() => useSetAtom(addRowAtom, tableScope)); + expect(addRow).toBeDefined(); + + await act(() => + addRow({ + row: { + _rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" }, + added: true, + }, + setId: "decrement", + }) + ); + + const { + result: { current: tableRows }, + } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); + expect(tableRows).toHaveLength(1); + expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeUndefined(); + expect(find(tableRows, ["added", true])).toBeDefined(); + expect(find(tableRows, ["added", true])?._rowy_ref.id).toBe(decrementId()); + }); +}); + describe("deleteRow", () => { test("deletes a single row", async () => { initRows(generatedRows); @@ -173,4 +307,19 @@ describe("deleteRow", () => { } = renderHook(() => useAtomValue(tableRowsAtom, tableScope)); expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH); }); + + test("doesn't delete from empty rows", async () => { + initRows(); + 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(0); + }); }); diff --git a/src/atoms/tableScope/rowActions.ts b/src/atoms/tableScope/rowActions.ts index 91cafffe..b4ac385c 100644 --- a/src/atoms/tableScope/rowActions.ts +++ b/src/atoms/tableScope/rowActions.ts @@ -1,5 +1,5 @@ import { atom } from "jotai"; -import { find } from "lodash-es"; +import { cloneDeep, find, set as _set, unset } from "lodash-es"; import { currentUserAtom } from "@src/atoms/globalScope"; import { @@ -7,22 +7,30 @@ import { tableSettingsAtom, tableFiltersAtom, tableRowsLocalAtom, + tableRowsAtom, _updateRowDbAtom, _deleteRowDbAtom, } from "./table"; import { tableColumnsOrderedAtom } from "./columnActions"; import { TableRow } from "@src/types/table"; -import { rowyUser } from "@src/utils/table"; +import { + rowyUser, + generateId, + decrementId, + updateRowData, +} from "@src/utils/table"; export interface IAddRowOptions { + /** The row or array of rows to add */ row: TableRow | TableRow[]; + /** If true, ignores checking required fields have values */ ignoreRequiredFields?: boolean; + /** Optionally overwite the IDs in the provided rows */ + setId?: "random" | "decrement"; } - /** * Adds a row or an array of rows. - * Adds to rowsDb if it has no missing required fields, - * otherwise to or rowsLocal. + * Adds to rowsDb if it has no missing required fields, otherwise to rowsLocal. * @param options - {@link IAddRowOptions} * * @example Basic usage: @@ -33,7 +41,7 @@ export interface IAddRowOptions { */ export const addRowAtom = atom( null, - async (get, set, { row, ignoreRequiredFields }: IAddRowOptions) => { + async (get, set, { row, ignoreRequiredFields, setId }: IAddRowOptions) => { const updateRowDb = get(_updateRowDbAtom); if (!updateRowDb) throw new Error("Cannot write to database"); const tableSettings = get(tableSettingsAtom); @@ -43,6 +51,7 @@ export const addRowAtom = atom( const auditChange = get(auditChangeAtom); const tableFilters = get(tableFiltersAtom); const tableColumnsOrdered = get(tableColumnsOrderedAtom); + const tableRows = get(tableRowsAtom); const _addSingleRowAndAudit = async (row: TableRow) => { // Store initial values to be written @@ -113,10 +122,43 @@ export const addRowAtom = atom( }; if (Array.isArray(row)) { - const promises = row.map(_addSingleRowAndAudit); + const promises: Promise[] = []; + + let lastId = tableRows[0]?._rowy_ref.id; + for (const r of row) { + const id = + setId === "random" + ? generateId() + : setId === "decrement" + ? decrementId(lastId) + : r._rowy_ref.id; + lastId = id; + + const path = setId + ? `${r._rowy_ref.path.split("/").slice(0, -1).join("/")}/${id}` + : r._rowy_ref.path; + + promises.push( + _addSingleRowAndAudit(setId ? { ...r, _rowy_ref: { id, path } } : r) + ); + } + await Promise.all(promises); } else { - await _addSingleRowAndAudit(row); + const id = + setId === "random" + ? generateId() + : setId === "decrement" + ? decrementId(tableRows[0]?._rowy_ref.id) + : row._rowy_ref.id; + + const path = setId + ? `${row._rowy_ref.path.split("/").slice(0, -1).join("/")}/${id}` + : row._rowy_ref.path; + + await _addSingleRowAndAudit( + setId ? { ...row, _rowy_ref: { id, path } } : row + ); } } ); @@ -138,10 +180,12 @@ export const deleteRowAtom = atom( if (!deleteRowDb) throw new Error("Cannot write to database"); const auditChange = get(auditChangeAtom); - const rowsLocal = get(tableRowsLocalAtom); + const tableRowsLocal = get(tableRowsLocalAtom); const _deleteSingleRowAndAudit = async (path: string) => { - const isLocalRow = Boolean(find(rowsLocal, ["_rowy_ref.path", path])); + const isLocalRow = Boolean( + find(tableRowsLocal, ["_rowy_ref.path", path]) + ); if (isLocalRow) set(tableRowsLocalAtom, { type: "delete", path }); else await deleteRowDb(path); if (auditChange) auditChange("DELETE_ROW", path); @@ -155,3 +199,107 @@ export const deleteRowAtom = atom( } } ); + +export interface IUpdateFieldOptions { + /** The path to the row to update */ + path: string; + /** The field name to update. Use dot notation to access nested fields. */ + fieldName: string; + /** The value to write */ + value: any; + /** Optionally, delete the field with fieldName. Use dot notation to access nested fields. */ + deleteField?: boolean; + /** If true, ignores checking required fields have values */ + ignoreRequiredFields?: boolean; +} +/** + * Updates or deletes a field in a row. + * Adds to rowsDb if it has no missing required fields, + * otherwise keeps in rowsLocal. + * @param options - {@link IAddRowOptions} + * + * @example Basic usage: + * ``` + * const updateField = useSetAtom(updateFieldAtom, tableScope); + * updateField({ path, fieldName: "", value: null, deleteField: true }); + * ``` + */ +export const updateFieldAtom = atom( + null, + async ( + get, + set, + { + path, + fieldName, + value, + deleteField, + ignoreRequiredFields, + }: IUpdateFieldOptions + ) => { + 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 tableColumnsOrdered = get(tableColumnsOrderedAtom); + const tableRows = get(tableRowsAtom); + const tableRowsLocal = get(tableRowsLocalAtom); + + const row = find(tableRows, ["_rowy_ref.path", path]); + if (!row) throw new Error("Could not find row"); + const isLocalRow = Boolean(find(tableRowsLocal, ["_rowy_ref.path", path])); + + const update: Partial = {}; + + // Write audit fields if not explicitly disabled + if (tableSettings.audit !== false) { + const auditValue = rowyUser(currentUser); + update[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); + + // Apply field update + if (!deleteField) _set(update, fieldName, value); + + // Update rowsLocal if any required fields are missing + if (missingRequiredFields.length > 0) { + set(tableRowsLocalAtom, { + type: "update", + path, + row: update, + deleteFields: deleteField ? [fieldName] : [], + }); + } + // If no required fields are missing and the row is only local, + // write the entire row to the database + else if (isLocalRow) { + const rowValues = updateRowData(cloneDeep(row), update); + if (deleteField) unset(rowValues, fieldName); + + await updateRowDb( + row._rowy_ref.path, + rowValues, + deleteField ? [fieldName] : [] + ); + } + // Otherwise, update single field in database + else { + await updateRowDb(path, update, deleteField ? [fieldName] : []); + } + + if (auditChange) + auditChange("UPDATE_CELL", path, { updatedField: fieldName }); + } +); diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts index f86f2519..9f85d2fe 100644 --- a/src/atoms/tableScope/table.ts +++ b/src/atoms/tableScope/table.ts @@ -1,6 +1,6 @@ import { atom } from "jotai"; import { atomWithReducer } from "jotai/utils"; -import { uniqBy, findIndex } from "lodash-es"; +import { uniqBy, findIndex, cloneDeep, unset } from "lodash-es"; import { TableSettings, @@ -8,6 +8,7 @@ import { TableFilter, TableOrder, TableRow, + UpdateDocFunction, UpdateCollectionDocFunction, DeleteCollectionDocFunction, } from "@src/types/table"; @@ -18,10 +19,10 @@ export const tableIdAtom = atom(undefined); /** Store tableSettings from project settings document */ export const tableSettingsAtom = atom(undefined); /** Store tableSchema from schema document */ -export const tableSchemaAtom = atom(undefined); +export const tableSchemaAtom = atom({}); /** Store function to update tableSchema */ export const updateTableSchemaAtom = atom< - ((update: Partial) => Promise) | undefined + UpdateDocFunction | undefined >(undefined); /** Filters applied to the local view */ @@ -37,7 +38,12 @@ type TableRowsLocalAction = /** Add a row or multiple rows */ | { type: "add"; row: TableRow | TableRow[] } /** Update a row */ - | { type: "update"; path: string; row: Partial } + | { + type: "update"; + path: string; + row: Partial; + deleteFields?: string[]; + } /** Delete a row or multiple rows */ | { type: "delete"; path: string | string[] }; const tableRowsLocalReducer = ( @@ -55,7 +61,13 @@ const tableRowsLocalReducer = ( const index = findIndex(prev, ["_rowy_ref.path", action.path]); if (index > -1) { const updatedRows = [...prev]; - updatedRows[index] = updateRowData(prev[index], action.row); + if (Array.isArray(action.deleteFields)) { + updatedRows[index] = cloneDeep(prev[index]); + for (const field of action.deleteFields) { + unset(updatedRows[index], field); + } + } + updatedRows[index] = updateRowData(updatedRows[index], action.row); return updatedRows; } // If not found, add to start diff --git a/src/hooks/useFirestoreCollectionWithAtom.ts b/src/hooks/useFirestoreCollectionWithAtom.ts index 4dc7b1d9..3bae1b98 100644 --- a/src/hooks/useFirestoreCollectionWithAtom.ts +++ b/src/hooks/useFirestoreCollectionWithAtom.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useAtom, PrimitiveAtom, useSetAtom } from "jotai"; import { Scope } from "jotai/core/atom"; +import { set } from "lodash-es"; import { query, collection, @@ -15,6 +16,7 @@ import { deleteDoc, CollectionReference, Query, + deleteField, } from "firebase/firestore"; import { useErrorHandler } from "react-error-boundary"; @@ -154,8 +156,17 @@ export function useFirestoreCollectionWithAtom( // set the atom’s value to a function that updates a doc in the collection if (updateDocAtom) { setUpdateDocAtom( - () => (path: string, update: T) => - setDoc(doc(firebaseDb, path), update, { merge: true }) + () => (path: string, update: T, deleteFields?: string[]) => { + const updateToDb = { ...update }; + + if (Array.isArray(deleteFields)) { + for (const field of deleteFields) { + set(updateToDb as any, field, deleteField()); + } + } + + return setDoc(doc(firebaseDb, path), updateToDb, { merge: true }); + } ); } diff --git a/src/hooks/useFirestoreDocWithAtom.ts b/src/hooks/useFirestoreDocWithAtom.ts index 882fe55a..17b83f20 100644 --- a/src/hooks/useFirestoreDocWithAtom.ts +++ b/src/hooks/useFirestoreDocWithAtom.ts @@ -1,12 +1,14 @@ import { useEffect } from "react"; import { useAtom, PrimitiveAtom, useSetAtom } from "jotai"; import { Scope } from "jotai/core/atom"; +import { set } from "lodash-es"; import { doc, onSnapshot, FirestoreError, setDoc, DocumentReference, + deleteField, } from "firebase/firestore"; import { useErrorHandler } from "react-error-boundary"; @@ -108,9 +110,17 @@ export function useFirestoreDocWithAtom( // If `options?.updateDataAtom` was passed, // set the atom’s value to a function that updates the document if (updateDataAtom) { - setUpdateDataAtom( - () => (update: T) => setDoc(ref, update, { merge: true }) - ); + setUpdateDataAtom(() => (update: T, deleteFields?: string[]) => { + const updateToDb = { ...update }; + + if (Array.isArray(deleteFields)) { + for (const field of deleteFields) { + set(updateToDb as any, field, deleteField()); + } + } + + return setDoc(ref, updateToDb, { merge: true }); + }); } return () => { diff --git a/src/pages/TableTest.tsx b/src/pages/TableTest.tsx index c5214076..0a725424 100644 --- a/src/pages/TableTest.tsx +++ b/src/pages/TableTest.tsx @@ -33,7 +33,7 @@ function TableTestPage() { const [tableRows] = useAtom(tableRowsAtom, tableScope); const [auditChange] = useAtom(auditChangeAtom, tableScope); - console.log(tableId, tableSchema); + console.log(tableId, tableSettings, tableSchema); const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); diff --git a/src/sources/TableSourceFirestore.tsx b/src/sources/TableSourceFirestore.tsx index c24e21df..a418a179 100644 --- a/src/sources/TableSourceFirestore.tsx +++ b/src/sources/TableSourceFirestore.tsx @@ -13,6 +13,7 @@ import { tableIdAtom, tableSettingsAtom, tableSchemaAtom, + updateTableSchemaAtom, tableFiltersAtom, tableOrdersAtom, tablePageAtom, @@ -63,7 +64,11 @@ const TableSourceFirestore = memo(function TableSourceFirestore() { tableSchemaAtom, tableScope, isCollectionGroup ? TABLE_GROUP_SCHEMAS : TABLE_SCHEMAS, - { pathSegments: [tableId], createIfNonExistent: { columns: {} } } + { + pathSegments: [tableId], + createIfNonExistent: { columns: {} }, + updateDataAtom: updateTableSchemaAtom, + } ); // Get table filters and orders diff --git a/src/types/table.d.ts b/src/types/table.d.ts index a335c8dd..ef950862 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -5,15 +5,35 @@ import type { DocumentReference, } from "firebase/firestore"; +/** + * A standard function to update a doc in the database + * @param update - The updates to be deeply merged with the existing doc. Note arrays should be ovewritten to match Firestore set with merge behavior + * @param deleteFields - Optionally, fields to be deleted from the doc. Access nested fields with dot notation + * @returns Promise when complete + */ export type UpdateDocFunction = ( - update: Partial + update: Partial, + deleteFields?: string[] ) => Promise; +/** + * A standard function to update a doc in a specific collection in the database + * @param path - The full path to the doc + * @param update - The updates to be deeply merged with the existing doc. Note arrays should be ovewritten to match Firestore set with merge behavior + * @param deleteFields - Optionally, fields to be deleted from the doc. Access nested fields with dot notation + * @returns Promise when complete + */ export type UpdateCollectionDocFunction = ( path: string, - update: Partial + update: Partial, + deleteFields?: string[] ) => Promise; +/** + * A standard function to delete a doc in a specific collection in the database + * @param path - The full path to the doc + * @returns Promise when complete + */ export type DeleteCollectionDocFunction = (path: string) => Promise; /** Table settings stored in project settings */ diff --git a/src/utils/table.ts b/src/utils/table.ts index 4c5712ae..5535dd48 100644 --- a/src/utils/table.ts +++ b/src/utils/table.ts @@ -1,6 +1,5 @@ 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 @@ -32,10 +31,65 @@ export const rowyUser = (currentUser: User, data?: Record) => { * @param update - The partial update to apply * @returns The row with updated values */ -export const updateRowData = (row: TableRow, update: Partial) => +export const updateRowData = >( + row: T, + update: Partial +): T => mergeWith( row, update, // If the proeprty to be merged is array, overwrite the array entirely (objValue, srcValue) => (isArray(objValue) ? srcValue : undefined) ); + +const ID_CHARACTERS = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +/** + * Generate an ID compatible with Firestore + * @param length - The length of the ID to generate + * @returns - Generated ID + */ +export const generateId = (length: number = 20) => { + let result = ""; + const charactersLength = ID_CHARACTERS.length; + for (var i = 0; i < length; i++) + result += ID_CHARACTERS.charAt( + Math.floor(Math.random() * charactersLength) + ); + + return result; +}; + +/** + * Lexicographically decrement a given ID + * @param id - The ID to decrement. If not provided, set to 20 `z` characters + * @returns - The decremented ID + */ +export const decrementId = (id: string = "zzzzzzzzzzzzzzzzzzzz") => { + const newId = id.split(""); + + // Loop through ID characters from the end + let i = newId.length - 1; + while (i > -1) { + const newCharacterIndex = ID_CHARACTERS.indexOf(newId[i]) - 1; + + newId[i] = + ID_CHARACTERS[ + newCharacterIndex > -1 ? newCharacterIndex : ID_CHARACTERS.length - 1 + ]; + + // If we don’t hit 0, we’re done + if (newCharacterIndex > -1) break; + + // Otherwise, if we hit 0, we need to decrement the next character + i--; + } + + // Ensure we don't get 00...0, then the next ID would be 00...0z, + // which would appear as the second row + if (newId.every((x) => x === ID_CHARACTERS[0])) + newId.push(ID_CHARACTERS[ID_CHARACTERS.length - 1]); + + return newId.join(""); +};