write addRow function

This commit is contained in:
Sidney Alcantara
2022-05-13 13:08:39 +10:00
parent 0d3672b538
commit a94bb2652f
9 changed files with 466 additions and 11 deletions

View File

@@ -2,4 +2,5 @@
export const tableScope = Symbol("tableScope");
export * from "./table";
export * from "./columns";
export * from "./columnActions";
export * from "./rowActions";

View File

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

View File

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

View File

@@ -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<string | undefined>(undefined);
@@ -29,8 +31,66 @@ export const tableOrdersAtom = atom<TableOrder[]>([]);
/** 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<TableRow[]>([]);
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<TableRow> }
/** 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<TableRow[]>([]);
/** 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<UpdateCollectionDocFunction | undefined>(

View File

@@ -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 (
<Suspense
@@ -115,7 +116,14 @@ export default function ProvidedTableTestPage() {
</>
}
>
<Provider key={id} scope={tableScope} initialValues={[[tableIdAtom, id]]}>
<Provider
key={id}
scope={tableScope}
initialValues={[
[tableIdAtom, id],
[currentUserAtom, currentUser],
]}
>
<TableSourceFirestore />
<TableTestPage />
</Provider>

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

@@ -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<typeof where>[0];
key: string;
operator: Parameters<typeof where>[1];
value: Parameters<typeof where>[2];
value: any;
};
export type TableOrder = {
key: Parameters<typeof orderBy>[0];
key: string;
direction: Parameters<typeof orderBy>[1];
};

41
src/utils/table.ts Normal file
View File

@@ -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<string, any>) => {
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 Firestores 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<TableRow>) =>
mergeWith(
row,
update,
// If the proeprty to be merged is array, overwrite the array entirely
(objValue, srcValue) => (isArray(objValue) ? srcValue : undefined)
);