mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
write addRow function
This commit is contained in:
@@ -2,4 +2,5 @@
|
||||
export const tableScope = Symbol("tableScope");
|
||||
|
||||
export * from "./table";
|
||||
export * from "./columns";
|
||||
export * from "./columnActions";
|
||||
export * from "./rowActions";
|
||||
|
||||
176
src/atoms/tableScope/rowActions.test.ts
Normal file
176
src/atoms/tableScope/rowActions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
157
src/atoms/tableScope/rowActions.ts
Normal file
157
src/atoms/tableScope/rowActions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
18
src/types/table.d.ts
vendored
@@ -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
41
src/utils/table.ts
Normal 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 Firestore’s setDoc with merge.
|
||||
* Merges objects recursively, but overwrites arrays.
|
||||
* @see https://stackoverflow.com/a/47554197/3572007
|
||||
* @param row - The source row to update
|
||||
* @param update - The partial update to apply
|
||||
* @returns The row with updated values
|
||||
*/
|
||||
export const updateRowData = (row: TableRow, update: Partial<TableRow>) =>
|
||||
mergeWith(
|
||||
row,
|
||||
update,
|
||||
// If the proeprty to be merged is array, overwrite the array entirely
|
||||
(objValue, srcValue) => (isArray(objValue) ? srcValue : undefined)
|
||||
);
|
||||
Reference in New Issue
Block a user