diff --git a/package.json b/package.json index 178ec9a9..3b622810 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "start": "cross-env PORT=7699 craco start", "startWithEmulators": "cross-env PORT=7699 REACT_APP_FIREBASE_EMULATORS=true craco start", "emulators": "firebase emulators:start --only firestore,auth --import ./emulators/ --export-on-exit", - "test": "craco test --env ./src/test/custom-jest-env.js", + "test": "craco test --env ./src/test/custom-jest-env.js --verbose --detectOpenHandles", "build": "craco build", "analyze": "source-map-explorer ./build/static/js/*.js", "prepare": "husky install", diff --git a/src/atoms/globalScope/project.ts b/src/atoms/globalScope/project.ts index f19a027e..6d9d6c0e 100644 --- a/src/atoms/globalScope/project.ts +++ b/src/atoms/globalScope/project.ts @@ -33,8 +33,9 @@ export type PublicSettings = Partial<{ /** Public settings are visible to unauthenticated users */ export const publicSettingsAtom = atom({}); /** Stores a function that updates public settings */ -export const updatePublicSettingsAtom = - atom | null>(null); +export const updatePublicSettingsAtom = atom< + UpdateDocFunction | undefined +>(undefined); /** Project settings are visible to authenticated users */ export type ProjectSettings = Partial<{ @@ -54,8 +55,9 @@ export type ProjectSettings = Partial<{ /** Project settings are visible to authenticated users */ export const projectSettingsAtom = atom({}); /** Stores a function that updates project settings */ -export const updateProjectSettingsAtom = - atom | null>(null); +export const updateProjectSettingsAtom = atom< + UpdateDocFunction | undefined +>(undefined); /** Tables visible to the signed-in user based on roles */ export const tablesAtom = atom((get) => { @@ -94,8 +96,8 @@ export const createTableAtom = atom< settings: TableSettings, additionalSettings?: AdditionalTableSettings ) => Promise) - | null ->(null); + | undefined +>(undefined); /** * Minimum amount of table settings required to be passed to updateTable to @@ -112,18 +114,18 @@ export const updateTableAtom = atom< settings: MinimumTableSettings, additionalSettings?: AdditionalTableSettings ) => Promise) - | null ->(null); + | undefined +>(undefined); /** Stores a function to delete a table and its schema doc */ -export const deleteTableAtom = atom<((id: string) => Promise) | null>( - null -); +export const deleteTableAtom = atom< + ((id: string) => Promise) | undefined +>(undefined); /** Stores a function to get a table’s schema doc (without listener) */ export const getTableSchemaAtom = atom< - ((id: string) => Promise) | null ->(null); + ((id: string) => Promise) | undefined +>(undefined); /** Roles used in the project based on table settings */ export const rolesAtom = atom((get) => @@ -140,5 +142,6 @@ export const rolesAtom = atom((get) => /** User management page: all users */ export const allUsersAtom = atom([]); /** Stores a function that updates a user document */ -export const updateUserAtom = - atom | null>(null); +export const updateUserAtom = atom< + UpdateCollectionFunction | undefined +>(undefined); diff --git a/src/atoms/globalScope/ui.ts b/src/atoms/globalScope/ui.ts index 8092bcf7..0ee1ad3c 100644 --- a/src/atoms/globalScope/ui.ts +++ b/src/atoms/globalScope/ui.ts @@ -126,7 +126,12 @@ export const tableSettingsDialogAtom = atom( } ); +/** + * Store the current ID of the table being edited in tableSettingsDialog + * to derive tableSettingsDialogSchemaAtom + */ export const tableSettingsDialogIdAtom = atom(""); +/** Get and store the schema document of the current table being edited */ export const tableSettingsDialogSchemaAtom = atom(async (get) => { const tableId = get(tableSettingsDialogIdAtom); const getTableSchema = get(getTableSchemaAtom); diff --git a/src/atoms/globalScope/user.ts b/src/atoms/globalScope/user.ts index f4144c27..c2e14788 100644 --- a/src/atoms/globalScope/user.ts +++ b/src/atoms/globalScope/user.ts @@ -34,8 +34,9 @@ export type UserSettings = Partial<{ /** User info and settings */ export const userSettingsAtom = atom({}); /** Stores a function that updates user settings */ -export const updateUserSettingsAtom = - atom | null>(null); +export const updateUserSettingsAtom = atom< + UpdateDocFunction | undefined +>(undefined); /** * Stores which theme is currently active, based on user or OS setting. diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts index c3b8bba9..9837040e 100644 --- a/src/atoms/tableScope/table.ts +++ b/src/atoms/tableScope/table.ts @@ -1,5 +1,5 @@ import { atom } from "jotai"; -import { uniqBy } from "lodash-es"; +import { uniqBy, orderBy, findIndex } from "lodash-es"; import { TableSettings, @@ -7,22 +7,147 @@ import { TableFilter, TableOrder, TableRow, + ColumnConfig, } from "@src/types/table"; +/** Root atom from which others are derived */ 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); +/** Store function to update tableSchema */ +export const updateTableSchemaAtom = atom< + ((update: Partial) => Promise) | undefined +>(undefined); +/** Store the table columns as an ordered array */ +export const tableColumnsOrderedAtom = atom((get) => { + const tableSchema = get(tableSchemaAtom); + if (!tableSchema || !tableSchema.columns) return []; + return orderBy(Object.values(tableSchema?.columns ?? {}), "index"); +}); +/** Reducer function to convert from array of columns to columns object */ +export const tableColumnsReducer = ( + a: Record, + c: ColumnConfig, + index: number +) => { + a[c.key] = { ...c, index }; + return a; +}; +/** + * Store function to add a column to tableSchema, to the end or by index. + * Also fixes any issues with column indexes, so they go from 0 to length - 1 + * @param config - Column config to add. `config.index` is ignored + * @param index - Index to add column at. If undefined, adds to end + */ +export const addColumnAtom = atom((get) => { + const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)]; + const updateTableSchema = get(updateTableSchemaAtom); + if (!updateTableSchema) { + return async (config: ColumnConfig, index?: number) => { + throw new Error("Cannot update table schema"); + }; + } + + return (config: Omit, index?: number) => { + // If index is provided, insert at index. Otherwise, append to end + tableColumnsOrdered.splice(index ?? tableColumnsOrdered.length, 0, { + ...config, + index: index ?? tableColumnsOrdered.length, + } as ColumnConfig); + + // Reduce array into single object with updated indexes + const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {}); + return updateTableSchema({ columns: updatedColumns }); + }; +}); + +/** + * Store function to update a column in tableSchema. If not found, throws error. + * @param key - Unique key of column to update + * @param config - Partial column config to add. `config.index` is ignored + * @param index - If passed, reorders the column to the index + */ +export const updateColumnAtom = atom((get) => { + const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)]; + const updateTableSchema = get(updateTableSchemaAtom); + if (!updateTableSchema) { + return async (key: string, config: Partial) => { + throw new Error("Cannot update table schema"); + }; + } + + return (key: string, config: Partial, index?: number) => { + const currentIndex = findIndex(tableColumnsOrdered, ["key", key]); + if (currentIndex === -1) + throw new Error(`Column with key "${key}" not found`); + + // If column is not being reordered, just update the config + if (!index) { + tableColumnsOrdered[currentIndex] = { + ...tableColumnsOrdered[currentIndex], + ...config, + index: currentIndex, + }; + } + // Otherwise, remove the column from the current position + // Then insert it at the new position + else { + const currentColumn = tableColumnsOrdered.splice(currentIndex, 1)[0]; + tableColumnsOrdered.splice(index, 0, { + ...currentColumn, + ...config, + index, + }); + } + + // Reduce array into single object with updated indexes + const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {}); + return updateTableSchema({ columns: updatedColumns }); + }; +}); + +/** + * Store function to delete a column in tableSchema + * @param key - Unique key of column to delete + */ +export const deleteColumnAtom = atom((get) => { + const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)]; + const updateTableSchema = get(updateTableSchemaAtom); + if (!updateTableSchema) { + return async (key: string) => { + throw new Error("Cannot update table schema"); + }; + } + + return (key: string) => { + const updatedColumns = tableColumnsOrdered + .filter((c) => c.key !== key) + .reduce(tableColumnsReducer, {}); + + return updateTableSchema({ columns: updatedColumns }); + }; +}); + +/** Filters applied to the local view */ export const tableFiltersAtom = atom([]); +/** Orders applied to the local view */ 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([]); +/** Store rows from the db listener */ export const tableRowsDbAtom = atom([]); +/** Combine tableRowsLocal and tableRowsDb */ export const tableRowsAtom = atom((get) => uniqBy( [...get(tableRowsLocalAtom), ...get(tableRowsDbAtom)], "_rowy_ref.path" ) ); +/** Store loading more state for infinite scroll */ export const tableLoadingMoreAtom = atom(false); diff --git a/src/hooks/useFirestoreCollectionWithAtom.ts b/src/hooks/useFirestoreCollectionWithAtom.ts index 7ad60d3f..eb78a3ae 100644 --- a/src/hooks/useFirestoreCollectionWithAtom.ts +++ b/src/hooks/useFirestoreCollectionWithAtom.ts @@ -1,7 +1,6 @@ import { useEffect } from "react"; import { useAtom, PrimitiveAtom, useSetAtom } from "jotai"; import { Scope } from "jotai/core/atom"; -import { RESET } from "jotai/utils"; import { query, collection, @@ -46,7 +45,7 @@ interface IUseFirestoreCollectionWithAtomOptions { /** Optionally disable Suspense */ disableSuspense?: boolean; /** Set this atom’s value to a function that updates a document in the collection. If `collectionGroup` is true, you must pass the full path. Uses same scope as `dataScope`. */ - updateDataAtom?: PrimitiveAtom | null>; + updateDataAtom?: PrimitiveAtom | undefined>; } /** @@ -163,7 +162,7 @@ export function useFirestoreCollectionWithAtom( unsubscribe(); // If `options?.updateDataAtom` was passed, // reset the atom’s value to prevent writes - if (updateDataAtom) setUpdateDataAtom(RESET); + if (updateDataAtom) setUpdateDataAtom(undefined); }; }, [ firebaseDb, diff --git a/src/hooks/useFirestoreDocWithAtom.ts b/src/hooks/useFirestoreDocWithAtom.ts index f9fce091..d6d71dc5 100644 --- a/src/hooks/useFirestoreDocWithAtom.ts +++ b/src/hooks/useFirestoreDocWithAtom.ts @@ -1,7 +1,6 @@ import { useEffect } from "react"; import { useAtom, PrimitiveAtom, useSetAtom } from "jotai"; import { Scope } from "jotai/core/atom"; -import { RESET } from "jotai/utils"; import { doc, onSnapshot, @@ -26,7 +25,7 @@ interface IUseFirestoreDocWithAtomOptions { /** Optionally create the document if it doesn’t exist with the following data */ createIfNonExistent?: T; /** Set this atom’s value to a function that updates the document. Uses same scope as `dataScope`. */ - updateDataAtom?: PrimitiveAtom | null>; + updateDataAtom?: PrimitiveAtom | undefined>; } /** @@ -115,7 +114,7 @@ export function useFirestoreDocWithAtom( unsubscribe(); // If `options?.updateDataAtom` was passed, // reset the atom’s value to prevent writes - if (updateDataAtom) setUpdateDataAtom(RESET); + if (updateDataAtom) setUpdateDataAtom(undefined); }; }, [ firebaseDb, diff --git a/src/sources/ProjectSourceFirebase/ProjectSourceFirebase.tsx b/src/sources/ProjectSourceFirebase/ProjectSourceFirebase.tsx index c3051e7b..ed3ab37d 100644 --- a/src/sources/ProjectSourceFirebase/ProjectSourceFirebase.tsx +++ b/src/sources/ProjectSourceFirebase/ProjectSourceFirebase.tsx @@ -27,7 +27,6 @@ export const ProjectSourceFirebase = memo(function ProjectSourceFirebase() { // Also sets functions to update those documents. useSettingsDocs(); useTableFunctions(); - console.log("rerender"); return null; }); diff --git a/src/sources/ProjectSourceFirebase/useTableFunctions.ts b/src/sources/ProjectSourceFirebase/useTableFunctions.ts index 7d950c0b..2e118385 100644 --- a/src/sources/ProjectSourceFirebase/useTableFunctions.ts +++ b/src/sources/ProjectSourceFirebase/useTableFunctions.ts @@ -38,7 +38,6 @@ export function useTableFunctions() { // Set the createTable function const setCreateTable = useSetAtom(createTableAtom, globalScope); useEffect(() => { - console.log("effect firebaseDb"); setCreateTable( () => async ( @@ -53,7 +52,6 @@ export function useTableFunctions() { // Get latest tables const tables = (await getTables()) || []; - console.log("createTable", tables); // Get columns from imported table settings or _schemaSource if provided let columns: NonNullable = diff --git a/src/test/tableAtoms.test.tsx b/src/test/tableAtoms.test.tsx new file mode 100644 index 00000000..39981bc4 --- /dev/null +++ b/src/test/tableAtoms.test.tsx @@ -0,0 +1,282 @@ +import { renderHook, act } from "@testing-library/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + tableScope, + tableSchemaAtom, + updateTableSchemaAtom, + addColumnAtom, + updateColumnAtom, + deleteColumnAtom, +} from "@src/atoms/tableScope"; +import { TableSchema } from "@src/types/table"; + +import { FieldType } from "@src/constants/fields"; + +const initUpdateTableSchemaAtom = (initialTableSchema?: TableSchema) => + renderHook(() => { + const setTableSchema = useSetAtom(tableSchemaAtom, tableScope); + setTableSchema(initialTableSchema ?? {}); + + const setUpdateTableSchema = useSetAtom(updateTableSchemaAtom, tableScope); + setUpdateTableSchema(() => async (update: Partial) => { + setTableSchema(update); + }); + }); + +const GENERATED_COLUMNS_LENGTH = 10; +const generatedColumns: TableSchema["columns"] = {}; +for (let i = 0; i < GENERATED_COLUMNS_LENGTH; i++) + generatedColumns[`column${i}`] = { + key: `column${i}`, + fieldName: `column${i}`, + name: `Column ${i}`, + type: FieldType.shortText, + index: i, + config: {}, + }; + +describe("addColumn", () => { + const columnToAdd = { + key: "firstName", + fieldName: "firstName", + name: "First Name", + type: FieldType.shortText, + index: 0, + config: {}, + }; + + test("adds a column to an empty schema", async () => { + initUpdateTableSchemaAtom(); + const { + result: { current: addColumn }, + } = renderHook(() => useAtomValue(addColumnAtom, tableScope)); + expect(addColumn).toBeDefined(); + + await act(() => addColumn(columnToAdd)); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(tableSchema?.columns).toHaveProperty("firstName"); + expect(tableSchema?.columns?.firstName).toStrictEqual(columnToAdd); + }); + + test("adds a column to the end", async () => { + initUpdateTableSchemaAtom({ columns: generatedColumns }); + const { + result: { current: addColumn }, + } = renderHook(() => useAtomValue(addColumnAtom, tableScope)); + expect(addColumn).toBeDefined(); + + await act(() => addColumn(columnToAdd)); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(tableSchema?.columns).toHaveProperty("firstName"); + expect(tableSchema?.columns?.firstName.index).toEqual( + GENERATED_COLUMNS_LENGTH + ); + }); + + test("adds a column at specified index", async () => { + initUpdateTableSchemaAtom({ columns: generatedColumns }); + const { + result: { current: addColumn }, + } = renderHook(() => useAtomValue(addColumnAtom, tableScope)); + expect(addColumn).toBeDefined(); + + await act(() => addColumn(columnToAdd, 7)); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(tableSchema?.columns).toHaveProperty("firstName"); + expect(tableSchema?.columns?.firstName.index).toEqual(7); + expect(tableSchema?.columns?.["column7"].index).toEqual(8); + expect(tableSchema?.columns?.["column8"].index).toEqual(9); + expect(tableSchema?.columns?.["column9"].index).toEqual(10); + }); +}); + +describe("updateColumn", () => { + test("updates a column without reordering", async () => { + initUpdateTableSchemaAtom({ columns: generatedColumns }); + const { + result: { current: updateColumn }, + } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + expect(updateColumn).toBeDefined(); + + await act(() => updateColumn("column7", { name: "Updated column" })); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(Object.keys(tableSchema?.columns ?? {})).toHaveLength( + GENERATED_COLUMNS_LENGTH + ); + expect(tableSchema?.columns?.column7.name).toEqual("Updated column"); + + for (let i = 0; i < GENERATED_COLUMNS_LENGTH; i++) { + expect(tableSchema?.columns?.[`column${i}`].index).toEqual(i); + } + }); + + test("updates a column and reorders forwards", async () => { + initUpdateTableSchemaAtom({ columns: generatedColumns }); + const { + result: { current: updateColumn }, + } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + expect(updateColumn).toBeDefined(); + + const SOURCE_INDEX = 2; + const TARGET_INDEX = 4; + await act(() => + updateColumn( + `column${SOURCE_INDEX}`, + { name: "Updated column" }, + TARGET_INDEX + ) + ); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(Object.keys(tableSchema?.columns ?? {})).toHaveLength( + GENERATED_COLUMNS_LENGTH + ); + expect(tableSchema?.columns?.[`column${SOURCE_INDEX}`].name).toEqual( + "Updated column" + ); + + for (let i = 0; i < GENERATED_COLUMNS_LENGTH; i++) { + let expectedIndex = i; + if (i === SOURCE_INDEX) expectedIndex = TARGET_INDEX; + else if (i > SOURCE_INDEX && i <= TARGET_INDEX) expectedIndex = i - 1; + + expect(tableSchema?.columns?.[`column${i}`].index).toEqual(expectedIndex); + } + }); + + test("updates a column and reorders backwards", async () => { + initUpdateTableSchemaAtom({ columns: generatedColumns }); + const { + result: { current: updateColumn }, + } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + expect(updateColumn).toBeDefined(); + + const SOURCE_INDEX = 9; + const TARGET_INDEX = 3; + await act(() => + updateColumn( + `column${SOURCE_INDEX}`, + { name: "Updated column" }, + TARGET_INDEX + ) + ); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(Object.keys(tableSchema?.columns ?? {})).toHaveLength( + GENERATED_COLUMNS_LENGTH + ); + expect(tableSchema?.columns?.[`column${SOURCE_INDEX}`].name).toEqual( + "Updated column" + ); + + for (let i = 0; i < GENERATED_COLUMNS_LENGTH; i++) { + let expectedIndex = i; + if (i === SOURCE_INDEX) expectedIndex = TARGET_INDEX; + else if (i < SOURCE_INDEX && i >= TARGET_INDEX) expectedIndex = i + 1; + + expect(tableSchema?.columns?.[`column${i}`].index).toEqual(expectedIndex); + } + }); + + test("doesn't update a column that doesn't exist", async () => { + initUpdateTableSchemaAtom({ columns: generatedColumns }); + const { + result: { current: updateColumn }, + } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + expect(updateColumn).toBeDefined(); + + expect(() => { + act(() => updateColumn("nonExistentColumn", {})); + }).toThrow(/Column with key .* not found/); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(tableSchema?.columns).toStrictEqual(generatedColumns); + }); + + test("doesn't update empty columns", async () => { + initUpdateTableSchemaAtom(); + const { + result: { current: updateColumn }, + } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + expect(updateColumn).toBeDefined(); + + expect(() => { + act(() => updateColumn("nonExistentColumn", {})); + }).toThrow(/Column with key .* not found/); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(Object.keys(tableSchema?.columns ?? {})).toHaveLength(0); + }); +}); + +describe("deleteColumn", () => { + test("deletes a column", async () => { + initUpdateTableSchemaAtom({ columns: generatedColumns }); + const { + result: { current: deleteColumn }, + } = renderHook(() => useAtomValue(deleteColumnAtom, tableScope)); + expect(deleteColumn).toBeDefined(); + + await act(() => deleteColumn("column7")); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(tableSchema?.columns).not.toHaveProperty("column7"); + expect(tableSchema?.columns?.["column8"].index).toEqual(7); + expect(tableSchema?.columns?.["column9"].index).toEqual(8); + }); + + test("doesn't delete a non-existent column", async () => { + initUpdateTableSchemaAtom({ columns: generatedColumns }); + const { + result: { current: deleteColumn }, + } = renderHook(() => useAtomValue(deleteColumnAtom, tableScope)); + expect(deleteColumn).toBeDefined(); + + await act(() => deleteColumn("column72")); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(tableSchema?.columns).toHaveProperty("column7"); + expect(Object.keys(tableSchema?.columns ?? {})).toHaveLength( + GENERATED_COLUMNS_LENGTH + ); + }); + + test("doesn't delete from empty columns", async () => { + initUpdateTableSchemaAtom(); + const { + result: { current: deleteColumn }, + } = renderHook(() => useAtomValue(deleteColumnAtom, tableScope)); + expect(deleteColumn).toBeDefined(); + + await act(() => deleteColumn("column7")); + + const { + result: { current: tableSchema }, + } = renderHook(() => useAtomValue(tableSchemaAtom, tableScope)); + expect(Object.keys(tableSchema?.columns ?? {})).toHaveLength(0); + }); +}); diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 34a056cb..0fbed2d5 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -19,6 +19,7 @@ export type TableSettings = { id: string; collection: string; name: string; + /** 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[]; section: string; @@ -46,15 +47,24 @@ export type TableSchema = { }; export type ColumnConfig = { - fieldName: string; + /** Unique key for this column. Currently set to the same as fieldName */ key: string; + /** Field key/name stored in document */ + fieldName: string; + /** User-facing name */ name: string; + /** Field type stored in config */ type: FieldType; + + /** Column index set by addColumn, updateColumn functions */ index: number; + width?: number; editable?: boolean; + + /** Column-specific config */ config: { [key: string]: any }; - [key: string]: any; + // [key: string]: any; }; export type TableFilter = {