diff --git a/emulators/auth_export/accounts.json b/emulators/auth_export/accounts.json index 9ed8781e..a2e193a1 100644 --- a/emulators/auth_export/accounts.json +++ b/emulators/auth_export/accounts.json @@ -1 +1 @@ -{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"26CJMrwlouNRwkiLofNK07DNgKhw","createdAt":"1651022832613","lastLoginAt":"1651727713722","displayName":"Admin User","photoUrl":"","customAttributes":"{\"roles\": [\"ADMIN\"]}","providerUserInfo":[{"providerId":"google.com","rawId":"abc123","federatedId":"abc123","displayName":"Admin User","email":"admin@example.com"},{"providerId":"password","email":"admin@example.com","federatedId":"admin@example.com","rawId":"admin@example.com","displayName":"Admin User","photoUrl":""}],"validSince":"1652147586","email":"admin@example.com","emailVerified":true,"disabled":false,"salt":"fakeSaltWjasmDYtQJU3vEm0cdg9","passwordHash":"fakeHash:salt=fakeSaltWjasmDYtQJU3vEm0cdg9:password=adminUser","passwordUpdatedAt":1652147586724,"lastRefreshAt":"2022-05-10T01:53:06.725Z"},{"localId":"3xTRVPnJGT2GE6lkiWKZp1jShuXj","createdAt":"1651023059442","lastLoginAt":"1651727720399","displayName":"Editor User","providerUserInfo":[{"providerId":"google.com","rawId":"1535779573397289142795231390488730790451","federatedId":"1535779573397289142795231390488730790451","displayName":"Editor User","email":"editor@example.com"},{"providerId":"password","email":"editor@example.com","federatedId":"editor@example.com","rawId":"editor@example.com","displayName":"Editor User","photoUrl":""}],"validSince":"1652147594","email":"editor@example.com","emailVerified":true,"disabled":false,"salt":"fakeSaltmNJg6BtcsUfyZKz6wZQY","passwordHash":"fakeHash:salt=fakeSaltmNJg6BtcsUfyZKz6wZQY:password=editorUser","passwordUpdatedAt":1652147594452,"photoUrl":"","customAttributes":"","lastRefreshAt":"2022-05-10T01:53:14.452Z"}]} \ No newline at end of file +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"26CJMrwlouNRwkiLofNK07DNgKhw","createdAt":"1651022832613","lastLoginAt":"1652237658250","displayName":"Admin User","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltWjasmDYtQJU3vEm0cdg9:password=adminUser","salt":"fakeSaltWjasmDYtQJU3vEm0cdg9","passwordUpdatedAt":1652169642047,"customAttributes":"{\"roles\": [\"ADMIN\"]}","providerUserInfo":[{"providerId":"google.com","rawId":"abc123","federatedId":"abc123","displayName":"Admin User","email":"admin@example.com"},{"providerId":"password","email":"admin@example.com","federatedId":"admin@example.com","rawId":"admin@example.com","displayName":"Admin User","photoUrl":""}],"validSince":"1652169642","email":"admin@example.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2022-05-11T02:54:18.250Z"},{"localId":"3xTRVPnJGT2GE6lkiWKZp1jShuXj","createdAt":"1651023059442","lastLoginAt":"1651727720399","displayName":"Editor User","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltmNJg6BtcsUfyZKz6wZQY:password=editorUser","salt":"fakeSaltmNJg6BtcsUfyZKz6wZQY","passwordUpdatedAt":1652169642047,"providerUserInfo":[{"providerId":"google.com","rawId":"1535779573397289142795231390488730790451","federatedId":"1535779573397289142795231390488730790451","displayName":"Editor User","email":"editor@example.com"},{"providerId":"password","email":"editor@example.com","federatedId":"editor@example.com","rawId":"editor@example.com","displayName":"Editor User","photoUrl":""}],"validSince":"1652169642","email":"editor@example.com","emailVerified":true,"disabled":false}]} \ No newline at end of file diff --git a/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index f4f8bebe..5bbf1600 100644 Binary files a/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/emulators/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/emulators/firestore_export/all_namespaces/all_kinds/output-0 b/emulators/firestore_export/all_namespaces/all_kinds/output-0 index 78e1cac6..eae31061 100644 Binary files a/emulators/firestore_export/all_namespaces/all_kinds/output-0 and b/emulators/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/emulators/firestore_export/firestore_export.overall_export_metadata b/emulators/firestore_export/firestore_export.overall_export_metadata index a87e2efa..5b309e57 100644 Binary files a/emulators/firestore_export/firestore_export.overall_export_metadata and b/emulators/firestore_export/firestore_export.overall_export_metadata differ diff --git a/src/atoms/globalScope/project.ts b/src/atoms/globalScope/project.ts index 6d9d6c0e..6505906e 100644 --- a/src/atoms/globalScope/project.ts +++ b/src/atoms/globalScope/project.ts @@ -6,7 +6,7 @@ import { userRolesAtom } from "./auth"; import { UserSettings } from "./user"; import { UpdateDocFunction, - UpdateCollectionFunction, + UpdateCollectionDocFunction, TableSettings, TableSchema, } from "@src/types/table"; @@ -54,7 +54,15 @@ export type ProjectSettings = Partial<{ }>; /** Project settings are visible to authenticated users */ export const projectSettingsAtom = atom({}); -/** Stores a function that updates project settings */ +/** + * Stores a function that updates project settings + * + * @example Basic usage: + * ``` + * const [updateProjectSettings] = useAtom(updateProjectSettingsAtom, globalScope); + * if (updateProjectSettings) updateProjectSettings({ ... }); + * ``` + */ export const updateProjectSettingsAtom = atom< UpdateDocFunction | undefined >(undefined); @@ -143,5 +151,5 @@ export const rolesAtom = atom((get) => export const allUsersAtom = atom([]); /** Stores a function that updates a user document */ export const updateUserAtom = atom< - UpdateCollectionFunction | undefined + UpdateCollectionDocFunction | undefined >(undefined); diff --git a/src/atoms/globalScope/user.ts b/src/atoms/globalScope/user.ts index c2e14788..808bf401 100644 --- a/src/atoms/globalScope/user.ts +++ b/src/atoms/globalScope/user.ts @@ -5,11 +5,11 @@ import { ThemeOptions } from "@mui/material"; import themes from "@src/theme"; import { publicSettingsAtom } from "./project"; -import { UpdateDocFunction, TableFilter } from "@src/types/table"; +import { UpdateDocFunction, TableFilter, TableRowRef } from "@src/types/table"; /** User info and settings */ export type UserSettings = Partial<{ - _rowy_id: string; + _rowy_ref: TableRowRef; /** Synced from user auth info */ user: { email: string; diff --git a/src/test/tableAtoms.test.tsx b/src/atoms/tableScope/columns.test.ts similarity index 81% rename from src/test/tableAtoms.test.tsx rename to src/atoms/tableScope/columns.test.ts index 39981bc4..d9b68965 100644 --- a/src/test/tableAtoms.test.tsx +++ b/src/atoms/tableScope/columns.test.ts @@ -1,5 +1,6 @@ import { renderHook, act } from "@testing-library/react"; import { useAtomValue, useSetAtom } from "jotai"; + import { tableScope, tableSchemaAtom, @@ -9,7 +10,6 @@ import { deleteColumnAtom, } from "@src/atoms/tableScope"; import { TableSchema } from "@src/types/table"; - import { FieldType } from "@src/constants/fields"; const initUpdateTableSchemaAtom = (initialTableSchema?: TableSchema) => @@ -49,10 +49,10 @@ describe("addColumn", () => { initUpdateTableSchemaAtom(); const { result: { current: addColumn }, - } = renderHook(() => useAtomValue(addColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(addColumnAtom, tableScope)); expect(addColumn).toBeDefined(); - await act(() => addColumn(columnToAdd)); + await act(() => addColumn({ config: columnToAdd })); const { result: { current: tableSchema }, @@ -65,10 +65,10 @@ describe("addColumn", () => { initUpdateTableSchemaAtom({ columns: generatedColumns }); const { result: { current: addColumn }, - } = renderHook(() => useAtomValue(addColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(addColumnAtom, tableScope)); expect(addColumn).toBeDefined(); - await act(() => addColumn(columnToAdd)); + await act(() => addColumn({ config: columnToAdd })); const { result: { current: tableSchema }, @@ -83,10 +83,10 @@ describe("addColumn", () => { initUpdateTableSchemaAtom({ columns: generatedColumns }); const { result: { current: addColumn }, - } = renderHook(() => useAtomValue(addColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(addColumnAtom, tableScope)); expect(addColumn).toBeDefined(); - await act(() => addColumn(columnToAdd, 7)); + await act(() => addColumn({ config: columnToAdd, index: 7 })); const { result: { current: tableSchema }, @@ -104,10 +104,12 @@ describe("updateColumn", () => { initUpdateTableSchemaAtom({ columns: generatedColumns }); const { result: { current: updateColumn }, - } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(updateColumnAtom, tableScope)); expect(updateColumn).toBeDefined(); - await act(() => updateColumn("column7", { name: "Updated column" })); + await act(() => + updateColumn({ key: "column7", config: { name: "Updated column" } }) + ); const { result: { current: tableSchema }, @@ -126,17 +128,17 @@ describe("updateColumn", () => { initUpdateTableSchemaAtom({ columns: generatedColumns }); const { result: { current: updateColumn }, - } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(updateColumnAtom, tableScope)); expect(updateColumn).toBeDefined(); const SOURCE_INDEX = 2; const TARGET_INDEX = 4; await act(() => - updateColumn( - `column${SOURCE_INDEX}`, - { name: "Updated column" }, - TARGET_INDEX - ) + updateColumn({ + key: `column${SOURCE_INDEX}`, + config: { name: "Updated column" }, + index: TARGET_INDEX, + }) ); const { @@ -162,17 +164,17 @@ describe("updateColumn", () => { initUpdateTableSchemaAtom({ columns: generatedColumns }); const { result: { current: updateColumn }, - } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(updateColumnAtom, tableScope)); expect(updateColumn).toBeDefined(); const SOURCE_INDEX = 9; const TARGET_INDEX = 3; await act(() => - updateColumn( - `column${SOURCE_INDEX}`, - { name: "Updated column" }, - TARGET_INDEX - ) + updateColumn({ + key: `column${SOURCE_INDEX}`, + config: { name: "Updated column" }, + index: TARGET_INDEX, + }) ); const { @@ -198,12 +200,16 @@ describe("updateColumn", () => { initUpdateTableSchemaAtom({ columns: generatedColumns }); const { result: { current: updateColumn }, - } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(updateColumnAtom, tableScope)); expect(updateColumn).toBeDefined(); - expect(() => { - act(() => updateColumn("nonExistentColumn", {})); - }).toThrow(/Column with key .* not found/); + let error = new Error(); + try { + await updateColumn({ key: "nonExistentColumn", config: {} }); + } catch (e: any) { + error = e; + } + expect(error?.message).toMatch(/Column with key .* not found/); const { result: { current: tableSchema }, @@ -215,12 +221,16 @@ describe("updateColumn", () => { initUpdateTableSchemaAtom(); const { result: { current: updateColumn }, - } = renderHook(() => useAtomValue(updateColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(updateColumnAtom, tableScope)); expect(updateColumn).toBeDefined(); - expect(() => { - act(() => updateColumn("nonExistentColumn", {})); - }).toThrow(/Column with key .* not found/); + let error = new Error(); + try { + await updateColumn({ key: "nonExistentColumn", config: {} }); + } catch (e: any) { + error = e; + } + expect(error?.message).toMatch(/Column with key .* not found/); const { result: { current: tableSchema }, @@ -234,7 +244,7 @@ describe("deleteColumn", () => { initUpdateTableSchemaAtom({ columns: generatedColumns }); const { result: { current: deleteColumn }, - } = renderHook(() => useAtomValue(deleteColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(deleteColumnAtom, tableScope)); expect(deleteColumn).toBeDefined(); await act(() => deleteColumn("column7")); @@ -251,7 +261,7 @@ describe("deleteColumn", () => { initUpdateTableSchemaAtom({ columns: generatedColumns }); const { result: { current: deleteColumn }, - } = renderHook(() => useAtomValue(deleteColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(deleteColumnAtom, tableScope)); expect(deleteColumn).toBeDefined(); await act(() => deleteColumn("column72")); @@ -269,7 +279,7 @@ describe("deleteColumn", () => { initUpdateTableSchemaAtom(); const { result: { current: deleteColumn }, - } = renderHook(() => useAtomValue(deleteColumnAtom, tableScope)); + } = renderHook(() => useSetAtom(deleteColumnAtom, tableScope)); expect(deleteColumn).toBeDefined(); await act(() => deleteColumn("column7")); diff --git a/src/atoms/tableScope/columns.ts b/src/atoms/tableScope/columns.ts new file mode 100644 index 00000000..3a5c3ffe --- /dev/null +++ b/src/atoms/tableScope/columns.ts @@ -0,0 +1,134 @@ +import { atom } from "jotai"; +import { orderBy, findIndex } from "lodash-es"; + +import { tableSchemaAtom, updateTableSchemaAtom } from "./table"; +import { ColumnConfig } from "@src/types/table"; + +/** 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; +}; + +export interface IAddColumnOptions { + /** Column config to add. `config.index` is ignored */ + config: Omit; + /** Index to add column at. If undefined, adds to end */ + index?: number; +} +/** + * 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 options - {@link IAddColumnOptions} + * + * @example Basic usage: + * ``` + * const addColumn = useSetAtom(addColumnAtom, tableScope); + * addColumn({ config: {...}, index?: 0 }); + * ``` + */ +export const addColumnAtom = atom( + null, + async (get, _set, { config, index }: IAddColumnOptions) => { + const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)]; + const updateTableSchema = get(updateTableSchemaAtom); + if (!updateTableSchema) throw new Error("Cannot update table schema"); + + // 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, {}); + await updateTableSchema({ columns: updatedColumns }); + } +); + +export interface IUpdateColumnOptions { + /** Unique key of column to update */ + key: string; + /** Partial column config to add. `config.index` is ignored */ + config: Partial; + /** If passed, reorders the column to the index */ + index?: number; +} +/** + * Store function to update a column in tableSchema + * @throws Error if column not found + * @param options - {@link IUpdateColumnOptions} + * + * @example Basic usage: + * ``` + * const updateColumn = useSetAtom(updateColumnAtom, tableScope); + * updateColumn({ key: "", config: {...}, index?: 0 }); + * ``` + */ +export const updateColumnAtom = atom( + null, + async (get, _set, { key, config, index }: IUpdateColumnOptions) => { + const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)]; + const updateTableSchema = get(updateTableSchemaAtom); + if (!updateTableSchema) throw new Error("Cannot update table schema"); + + 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, {}); + await updateTableSchema({ columns: updatedColumns }); + } +); + +/** + * Store function to delete a column in tableSchema + * @param key - Unique key of column to delete + * + * @example Basic usage: + * ``` + * const deleteColumn = useSetAtom(deleteColumnAtom, tableScope); + * deleteColumn(" ... "); + * ``` + */ +export const deleteColumnAtom = atom(null, async (get, _set, key: string) => { + const tableColumnsOrdered = [...get(tableColumnsOrderedAtom)]; + const updateTableSchema = get(updateTableSchemaAtom); + if (!updateTableSchema) throw new Error("Cannot update table schema"); + + const updatedColumns = tableColumnsOrdered + .filter((c) => c.key !== key) + .reduce(tableColumnsReducer, {}); + + await updateTableSchema({ columns: updatedColumns }); +}); diff --git a/src/atoms/tableScope/index.ts b/src/atoms/tableScope/index.ts index e5e34cac..29afc6ef 100644 --- a/src/atoms/tableScope/index.ts +++ b/src/atoms/tableScope/index.ts @@ -2,3 +2,4 @@ export const tableScope = Symbol("tableScope"); export * from "./table"; +export * from "./columns"; diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts index 9837040e..b03b3f8b 100644 --- a/src/atoms/tableScope/table.ts +++ b/src/atoms/tableScope/table.ts @@ -1,5 +1,5 @@ import { atom } from "jotai"; -import { uniqBy, orderBy, findIndex } from "lodash-es"; +import { uniqBy } from "lodash-es"; import { TableSettings, @@ -7,7 +7,8 @@ import { TableFilter, TableOrder, TableRow, - ColumnConfig, + UpdateCollectionDocFunction, + DeleteCollectionDocFunction, } from "@src/types/table"; /** Root atom from which others are derived */ @@ -20,116 +21,6 @@ export const tableSchemaAtom = atom(undefined); 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([]); @@ -151,3 +42,40 @@ export const tableRowsAtom = atom((get) => ); /** Store loading more state for infinite scroll */ 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 + * @internal Use {@link addRowAtom} or {@link updateRowAtom} instead + */ +export const _updateRowDbAtom = atom( + undefined +); +/** + * Store function to delete row in db directly + * @internal Use {@link deleteRowAtom} instead + */ +export const _deleteRowDbAtom = atom( + undefined +); + +export type AuditChangeFunction = ( + type: "ADD_ROW" | "UPDATE_CELL" | "DELETE_ROW", + rowId: string, + data?: + | { + updatedField?: string | undefined; + } + | undefined +) => Promise; +/** + * Store function to write auditing logs when user makes changes to the table. + * Silently fails if auditing is disabled for the table or Rowy Run version + * not compatible. + * + * @param type - Action type: "ADD_ROW" | "UPDATE_CELL" | "DELETE_ROW" + * @param rowId - ID of row updated + * @param data - Optional additional data to log + */ +export const auditChangeAtom = atom(undefined); diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 65099a2b..44702c8a 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -16,7 +16,7 @@ import { globalScope, confirmDialogAtom } from "@src/atoms/globalScope"; /** * Display a confirm dialog using `confirmDialogAtom` in `globalState` - * {@link confirmDialogAtom | See usage example} + * @see {@link confirmDialogAtom | Usage example} */ export default function ConfirmDialog() { const [ diff --git a/src/components/RowyRunModal.tsx b/src/components/RowyRunModal.tsx index fb2757ad..f801eb9c 100644 --- a/src/components/RowyRunModal.tsx +++ b/src/components/RowyRunModal.tsx @@ -24,7 +24,7 @@ import { WIKI_LINKS } from "@src/constants/externalLinks"; /** * Display a modal asking the user to deploy or upgrade Rowy Run * using `rowyRunModalAtom` in `globalState` - * {@link rowyRunModalAtom | See usage example} + * @see {@link rowyRunModalAtom | Usage example} */ export default function RowyRunModal() { const [userRoles] = useAtom(userRolesAtom, globalScope); diff --git a/src/components/Settings/UserManagement/UserItem.tsx b/src/components/Settings/UserManagement/UserItem.tsx index 5bf40b42..48db5736 100644 --- a/src/components/Settings/UserManagement/UserItem.tsx +++ b/src/components/Settings/UserManagement/UserItem.tsx @@ -30,7 +30,7 @@ import { runRoutes } from "@src/constants/runRoutes"; import { USERS } from "@src/config/dbPaths"; export default function UserItem({ - _rowy_id, + _rowy_ref, user, roles: rolesProp, }: UserSettings) { @@ -59,7 +59,7 @@ export default function UserItem({ }); if (res.success) { if (!updateUser) throw new Error("Could not update user document"); - await updateUser(_rowy_id!, { roles: value }); + await updateUser(_rowy_ref!.id, { roles: value }); closeSnackbar(loadingSnackbarId); enqueueSnackbar(`Set roles for ${user!.email}: ${value.join(", ")}`); } @@ -179,9 +179,11 @@ export default function UserItem({ { - if (!_rowy_id) return; - await navigator.clipboard.writeText(_rowy_id); - enqueueSnackbar(`Copied UID for ${user?.email}: ${_rowy_id}`); + if (!_rowy_ref?.id) return; + await navigator.clipboard.writeText(_rowy_ref.id); + enqueueSnackbar( + `Copied UID for ${user?.email}: ${_rowy_ref.id}` + ); }} > diff --git a/src/hooks/useFirestoreCollectionWithAtom.ts b/src/hooks/useFirestoreCollectionWithAtom.ts index eb78a3ae..4dc7b1d9 100644 --- a/src/hooks/useFirestoreCollectionWithAtom.ts +++ b/src/hooks/useFirestoreCollectionWithAtom.ts @@ -12,6 +12,7 @@ import { FirestoreError, setDoc, doc, + deleteDoc, CollectionReference, Query, } from "firebase/firestore"; @@ -19,7 +20,8 @@ import { useErrorHandler } from "react-error-boundary"; import { globalScope } from "@src/atoms/globalScope"; import { - UpdateCollectionFunction, + UpdateCollectionDocFunction, + DeleteCollectionDocFunction, TableFilter, TableOrder, TableRow, @@ -44,8 +46,10 @@ interface IUseFirestoreCollectionWithAtomOptions { onError?: (error: FirestoreError) => void; /** 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 | undefined>; + /** Set this atom’s value to a function that updates a document in the collection. Must pass the full path. Uses same scope as `dataScope`. */ + updateDocAtom?: PrimitiveAtom | undefined>; + /** Set this atom’s value to a function that deletes a document in the collection. Must pass the full path. Uses same scope as `dataScope`. */ + deleteDocAtom?: PrimitiveAtom; } /** @@ -66,9 +70,13 @@ export function useFirestoreCollectionWithAtom( ) { const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); const setDataAtom = useSetAtom(dataAtom, dataScope); - const setUpdateDataAtom = useSetAtom( - options?.updateDataAtom || (dataAtom as any), - globalScope + const setUpdateDocAtom = useSetAtom( + options?.updateDocAtom || (dataAtom as any), + dataScope + ); + const setDeleteDocAtom = useSetAtom( + options?.deleteDocAtom || (dataAtom as any), + dataScope ); const handleError = useErrorHandler(); @@ -81,7 +89,8 @@ export function useFirestoreCollectionWithAtom( limit = DEFAULT_COLLECTION_QUERY_LIMIT, onError, disableSuspense, - updateDataAtom, + updateDocAtom, + deleteDocAtom, } = options || {}; useEffect(() => { @@ -96,8 +105,7 @@ export function useFirestoreCollectionWithAtom( suspended = true; } - // Create a collection or collection group reference - // to query data and to use in `updateDataAtom` + // Create a collection or collection group reference to query data const collectionRef = collectionGroup ? (queryCollectionGroup( firebaseDb, @@ -108,6 +116,7 @@ export function useFirestoreCollectionWithAtom( path, ...((pathSegments as string[]) || []) ) as CollectionReference); + // Create the query with filters and orders const _query = query( collectionRef, @@ -122,11 +131,9 @@ export function useFirestoreCollectionWithAtom( _query, (querySnapshot) => { try { - // Extract doc data from query - // and add `_rowy_id` and `_rowy_ref` fields + // Extract doc data from query and add `_rowy_ref` fields const docs = querySnapshot.docs.map((doc) => ({ ...doc.data(), - _rowy_id: doc.id, _rowy_ref: doc.ref, })); setDataAtom(docs); @@ -143,26 +150,31 @@ export function useFirestoreCollectionWithAtom( } ); - // If `options?.updateDataAtom` was passed, + // If `options?.updateDocAtom` was passed, // set the atom’s value to a function that updates a doc in the collection - if (updateDataAtom) { - setUpdateDataAtom( + if (updateDocAtom) { + setUpdateDocAtom( () => (path: string, update: T) => - setDoc( - collectionGroup - ? doc(firebaseDb, path) - : doc(collectionRef as CollectionReference, path), - update, - { merge: true } - ) + setDoc(doc(firebaseDb, path), update, { merge: true }) + ); + } + + // If `options?.deleteDocAtom` was passed, + // set the atom’s value to a function that deletes a doc in the collection + if (deleteDocAtom) { + setDeleteDocAtom( + () => (path: string) => deleteDoc(doc(firebaseDb, path)) ); } return () => { unsubscribe(); - // If `options?.updateDataAtom` was passed, + // If `options?.updateDocAtom` was passed, // reset the atom’s value to prevent writes - if (updateDataAtom) setUpdateDataAtom(undefined); + if (updateDocAtom) setUpdateDocAtom(undefined); + // If `options?.deleteDoc` was passed, + // reset the atom’s value to prevent deletes + if (deleteDocAtom) setDeleteDocAtom(undefined); }; }, [ firebaseDb, @@ -176,8 +188,10 @@ export function useFirestoreCollectionWithAtom( setDataAtom, disableSuspense, handleError, - updateDataAtom, - setUpdateDataAtom, + updateDocAtom, + setUpdateDocAtom, + deleteDocAtom, + setDeleteDocAtom, ]); } diff --git a/src/hooks/useFirestoreDocWithAtom.ts b/src/hooks/useFirestoreDocWithAtom.ts index d6d71dc5..882fe55a 100644 --- a/src/hooks/useFirestoreDocWithAtom.ts +++ b/src/hooks/useFirestoreDocWithAtom.ts @@ -85,9 +85,12 @@ export function useFirestoreDocWithAtom( try { if (!docSnapshot.exists() && !!createIfNonExistent) { setDoc(docSnapshot.ref, createIfNonExistent); - setDataAtom(createIfNonExistent); + setDataAtom({ ...createIfNonExistent, _rowy_ref: docSnapshot.ref }); } else { - setDataAtom(docSnapshot.data() || ({} as T)); + setDataAtom({ + ...(docSnapshot.data() as T), + _rowy_ref: docSnapshot.ref, + }); } } catch (error) { if (onError) onError(error as FirestoreError); diff --git a/src/pages/Settings/UserManagement.tsx b/src/pages/Settings/UserManagement.tsx index 1bd921fe..045ae49b 100644 --- a/src/pages/Settings/UserManagement.tsx +++ b/src/pages/Settings/UserManagement.tsx @@ -59,7 +59,7 @@ function UserManagementPage() { {results.map((user) => ( - + ))} diff --git a/src/pages/TableTest.tsx b/src/pages/TableTest.tsx index 06dd4f87..9fd539b8 100644 --- a/src/pages/TableTest.tsx +++ b/src/pages/TableTest.tsx @@ -10,12 +10,18 @@ import { tableFiltersAtom, tableOrdersAtom, tableRowsAtom, + auditChangeAtom, } from "@src/atoms/tableScope"; import TableSourceFirestore from "@src/sources/TableSourceFirestore"; import TableHeaderSkeleton from "@src/components/Table/Skeleton/TableHeaderSkeleton"; import HeaderRowSkeleton from "@src/components/Table/Skeleton/HeaderRowSkeleton"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; +import { globalScope } from "@src/atoms/globalScope"; +import { doc, setDoc, updateDoc } from "firebase/firestore"; +import { TABLE_SCHEMAS } from "@src/config/dbPaths"; + function TableTestPage() { const [tableId] = useAtom(tableIdAtom, tableScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope); @@ -25,22 +31,51 @@ function TableTestPage() { const setTableOrders = useSetAtom(tableOrdersAtom, tableScope); const [tableRows] = useAtom(tableRowsAtom, tableScope); + const [auditChange] = useAtom(auditChangeAtom, tableScope); console.log(tableId, tableSchema); + const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); + return (

Table ID: {tableId}

-
+      
         tableSettings: {JSON.stringify(tableSettings, undefined, 2)}
       
-
+      
         tableSchema: {JSON.stringify(tableSchema, undefined, 2)}
       
+ + +
+
    - {tableRows.map(({ _rowy_id, ...data }) => ( -
  1. - {_rowy_id}: {data.firstName} {data.signedUp.toString()} + {tableRows.map(({ _rowy_ref, ...data }) => ( +
  2. + {_rowy_ref.id}: {data.firstName} {data.signedUp.toString()}
  3. ))}
diff --git a/src/sources/ProjectSourceFirebase/useTableFunctions.ts b/src/sources/ProjectSourceFirebase/useTableFunctions.ts index 2e118385..fdc33119 100644 --- a/src/sources/ProjectSourceFirebase/useTableFunctions.ts +++ b/src/sources/ProjectSourceFirebase/useTableFunctions.ts @@ -30,7 +30,7 @@ export function useTableFunctions() { // Create a function to get the latest tables from project settings, // so we don’t create new functions when tables change - const getTables = useAtomCallback( + const readTables = useAtomCallback( useCallback((get) => get(projectSettingsAtom).tables, []), globalScope ); @@ -51,7 +51,7 @@ export function useTableFunctions() { } = additionalSettings || {}; // Get latest tables - const tables = (await getTables()) || []; + const tables = (await readTables()) || []; // Get columns from imported table settings or _schemaSource if provided let columns: NonNullable = @@ -120,7 +120,7 @@ export function useTableFunctions() { await Promise.all([promiseUpdateSettings, promiseAddSchema]); } ); - }, [firebaseDb, getTables, setCreateTable]); + }, [firebaseDb, readTables, setCreateTable]); // Set the createTable function const setUpdateTable = useSetAtom(updateTableAtom, globalScope); @@ -134,7 +134,7 @@ export function useTableFunctions() { const { _schema } = additionalSettings || {}; // Get latest tables - const tables = [...((await getTables()) || [])]; + const tables = [...((await readTables()) || [])]; const foundIndex = findIndex(tables, ["id", settings.id]); const tableIndex = foundIndex > -1 ? foundIndex : tables.length; @@ -166,14 +166,14 @@ export function useTableFunctions() { await Promise.all([promiseUpdateSettings, promiseUpdateSchema]); } ); - }, [firebaseDb, getTables, setUpdateTable]); + }, [firebaseDb, readTables, setUpdateTable]); // Set the deleteTable function const setDeleteTable = useSetAtom(deleteTableAtom, globalScope); useEffect(() => { setDeleteTable(() => async (id: string) => { // Get latest tables - const tables = (await getTables()) || []; + const tables = (await readTables()) || []; const table = find(tables, ["id", id]); // Removes table from settings doc array @@ -196,14 +196,14 @@ export function useTableFunctions() { // Wait for both to complete await Promise.all([promiseUpdateSettings, promiseDeleteSchema]); }); - }, [firebaseDb, getTables, setDeleteTable]); + }, [firebaseDb, readTables, setDeleteTable]); // Set the getTableSchema function const setGetTableSchema = useSetAtom(getTableSchemaAtom, globalScope); useEffect(() => { setGetTableSchema(() => async (id: string) => { // Get latest tables - const tables = (await getTables()) || []; + const tables = (await readTables()) || []; const table = find(tables, ["id", id]); const tableSchemaDocRef = doc( @@ -217,5 +217,5 @@ export function useTableFunctions() { (doc) => (doc.data() || {}) as TableSchema ); }); - }, [firebaseDb, getTables, setGetTableSchema]); + }, [firebaseDb, readTables, setGetTableSchema]); } diff --git a/src/sources/TableSourceFirestore.tsx b/src/sources/TableSourceFirestore.tsx index 2bb9d5f4..c24e21df 100644 --- a/src/sources/TableSourceFirestore.tsx +++ b/src/sources/TableSourceFirestore.tsx @@ -2,7 +2,12 @@ import { memo, useMemo, useEffect } from "react"; import { useAtom, useSetAtom } from "jotai"; import { find } from "lodash-es"; -import { globalScope, tablesAtom } from "@src/atoms/globalScope"; +import { + globalScope, + tablesAtom, + rowyRunAtom, + compatibleRowyRunVersionAtom, +} from "@src/atoms/globalScope"; import { tableScope, tableIdAtom, @@ -12,6 +17,9 @@ import { tableOrdersAtom, tablePageAtom, tableRowsDbAtom, + _updateRowDbAtom, + _deleteRowDbAtom, + auditChangeAtom, } from "@src/atoms/tableScope"; import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom"; @@ -23,6 +31,7 @@ import { TABLE_SCHEMAS, TABLE_GROUP_SCHEMAS } from "@src/config/dbPaths"; import type { FirestoreError } from "firebase/firestore"; import { useSnackbar } from "notistack"; import { useErrorHandler } from "react-error-boundary"; +import { runRoutes } from "@src/constants/runRoutes"; import { Button } from "@mui/material"; import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; @@ -30,7 +39,6 @@ export const ERROR_TABLE_NOT_FOUND = "Table not found"; const TableSourceFirestore = memo(function TableSourceFirestore() { const [tables] = useAtom(tablesAtom, globalScope); - // const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); // Get tableSettings from tableId and tables in globalScope const [tableId] = useAtom(tableIdAtom, tableScope); @@ -77,9 +85,54 @@ const TableSourceFirestore = memo(function TableSourceFirestore() { collectionGroup: isCollectionGroup, onError: (error) => handleFirestoreError(error, enqueueSnackbar, elevateError), + updateDocAtom: _updateRowDbAtom, + deleteDocAtom: _deleteRowDbAtom, } ); + // Set auditChange function + const setAuditChange = useSetAtom(auditChangeAtom, tableScope); + const [rowyRun] = useAtom(rowyRunAtom, globalScope); + const [compatibleRowyRunVersion] = useAtom( + compatibleRowyRunVersionAtom, + globalScope + ); + useEffect(() => { + if ( + !tableSettings?.id || + !tableSettings?.collection || + !tableSettings.audit || + !compatibleRowyRunVersion({ minVersion: "1.1.1" }) + ) { + setAuditChange(undefined); + return; + } + + setAuditChange( + () => + ( + type: "ADD_ROW" | "UPDATE_CELL" | "DELETE_ROW", + rowId: string, + data?: { updatedField?: string } + ) => + rowyRun({ + route: runRoutes.auditChange, + body: { + type, + ref: { + rowPath: tableSettings.collection, + rowId, + tableId: tableSettings.id, + collectionPath: tableSettings.collection, + }, + data, + }, + }) + ); + + return () => setAuditChange(undefined); + }, [setAuditChange, rowyRun, compatibleRowyRunVersion, tableSettings]); + return null; }); diff --git a/src/sources/UserManagementSourceFirebase.tsx b/src/sources/UserManagementSourceFirebase.tsx index beb244dc..5a40164f 100644 --- a/src/sources/UserManagementSourceFirebase.tsx +++ b/src/sources/UserManagementSourceFirebase.tsx @@ -11,7 +11,7 @@ import { USERS } from "@src/config/dbPaths"; const UserManagementSourceFirebase = memo( function UserManagementSourceFirebase() { useFirestoreCollectionWithAtom(allUsersAtom, globalScope, USERS, { - updateDataAtom: updateUserAtom, + updateDocAtom: updateUserAtom, }); return null; diff --git a/src/test/App.test.tsx b/src/test/App.test.tsx.disabled similarity index 100% rename from src/test/App.test.tsx rename to src/test/App.test.tsx.disabled diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 0fbed2d5..62d8c171 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -9,11 +9,13 @@ export type UpdateDocFunction = ( update: Partial ) => Promise; -export type UpdateCollectionFunction = ( +export type UpdateCollectionDocFunction = ( path: string, update: Partial ) => Promise; +export type DeleteCollectionDocFunction = (path: string) => Promise; + /** Table settings stored in project settings */ export type TableSettings = { id: string; @@ -78,10 +80,13 @@ export type TableOrder = { direction: Parameters[1]; }; -export type TableRow = DocumentData & { - _rowy_id: string; - _rowy_ref: { id: string; path: string } & Partial; +export type TableRowRef = { + id: string; + path: string; +} & Partial; +export type TableRow = DocumentData & { + _rowy_ref: TableRowRef; _rowy_missingRequiredFields?: string[]; _rowy_outOfOrder?: boolean; };