add addColumn, updateColumn, deleteColumn functions + tests

This commit is contained in:
Sidney Alcantara
2022-05-10 16:18:31 +10:00
parent 72cac00c89
commit 5211d1d013
11 changed files with 451 additions and 30 deletions

View File

@@ -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",

View File

@@ -33,8 +33,9 @@ export type PublicSettings = Partial<{
/** Public settings are visible to unauthenticated users */
export const publicSettingsAtom = atom<PublicSettings>({});
/** Stores a function that updates public settings */
export const updatePublicSettingsAtom =
atom<UpdateDocFunction<PublicSettings> | null>(null);
export const updatePublicSettingsAtom = atom<
UpdateDocFunction<PublicSettings> | 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<ProjectSettings>({});
/** Stores a function that updates project settings */
export const updateProjectSettingsAtom =
atom<UpdateDocFunction<ProjectSettings> | null>(null);
export const updateProjectSettingsAtom = atom<
UpdateDocFunction<ProjectSettings> | undefined
>(undefined);
/** Tables visible to the signed-in user based on roles */
export const tablesAtom = atom<TableSettings[]>((get) => {
@@ -94,8 +96,8 @@ export const createTableAtom = atom<
settings: TableSettings,
additionalSettings?: AdditionalTableSettings
) => Promise<void>)
| 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<void>)
| null
>(null);
| undefined
>(undefined);
/** Stores a function to delete a table and its schema doc */
export const deleteTableAtom = atom<((id: string) => Promise<void>) | null>(
null
);
export const deleteTableAtom = atom<
((id: string) => Promise<void>) | undefined
>(undefined);
/** Stores a function to get a tables schema doc (without listener) */
export const getTableSchemaAtom = atom<
((id: string) => Promise<TableSchema>) | null
>(null);
((id: string) => Promise<TableSchema>) | 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<UserSettings[]>([]);
/** Stores a function that updates a user document */
export const updateUserAtom =
atom<UpdateCollectionFunction<UserSettings> | null>(null);
export const updateUserAtom = atom<
UpdateCollectionFunction<UserSettings> | undefined
>(undefined);

View File

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

View File

@@ -34,8 +34,9 @@ export type UserSettings = Partial<{
/** User info and settings */
export const userSettingsAtom = atom<UserSettings>({});
/** Stores a function that updates user settings */
export const updateUserSettingsAtom =
atom<UpdateDocFunction<UserSettings> | null>(null);
export const updateUserSettingsAtom = atom<
UpdateDocFunction<UserSettings> | undefined
>(undefined);
/**
* Stores which theme is currently active, based on user or OS setting.

View File

@@ -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<string | undefined>(undefined);
/** Store tableSettings from project settings document */
export const tableSettingsAtom = atom<TableSettings | undefined>(undefined);
/** Store tableSchema from schema document */
export const tableSchemaAtom = atom<TableSchema | undefined>(undefined);
/** Store function to update tableSchema */
export const updateTableSchemaAtom = atom<
((update: Partial<TableSchema>) => Promise<void>) | undefined
>(undefined);
/** Store the table columns as an ordered array */
export const tableColumnsOrderedAtom = atom<ColumnConfig[]>((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<string, ColumnConfig>,
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<ColumnConfig, "index">, 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<ColumnConfig>) => {
throw new Error("Cannot update table schema");
};
}
return (key: string, config: Partial<ColumnConfig>, 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<TableFilter[]>([]);
/** Orders applied to the local view */
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[]>([]);
/** Store rows from the db listener */
export const tableRowsDbAtom = atom<TableRow[]>([]);
/** Combine tableRowsLocal and tableRowsDb */
export const tableRowsAtom = atom<TableRow[]>((get) =>
uniqBy(
[...get(tableRowsLocalAtom), ...get(tableRowsDbAtom)],
"_rowy_ref.path"
)
);
/** Store loading more state for infinite scroll */
export const tableLoadingMoreAtom = atom(false);

View File

@@ -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<T> {
/** Optionally disable Suspense */
disableSuspense?: boolean;
/** Set this atoms 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<UpdateCollectionFunction<T> | null>;
updateDataAtom?: PrimitiveAtom<UpdateCollectionFunction<T> | undefined>;
}
/**
@@ -163,7 +162,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
unsubscribe();
// If `options?.updateDataAtom` was passed,
// reset the atoms value to prevent writes
if (updateDataAtom) setUpdateDataAtom(RESET);
if (updateDataAtom) setUpdateDataAtom(undefined);
};
}, [
firebaseDb,

View File

@@ -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<T> {
/** Optionally create the document if it doesnt exist with the following data */
createIfNonExistent?: T;
/** Set this atoms value to a function that updates the document. Uses same scope as `dataScope`. */
updateDataAtom?: PrimitiveAtom<UpdateDocFunction<T> | null>;
updateDataAtom?: PrimitiveAtom<UpdateDocFunction<T> | undefined>;
}
/**
@@ -115,7 +114,7 @@ export function useFirestoreDocWithAtom<T = TableRow>(
unsubscribe();
// If `options?.updateDataAtom` was passed,
// reset the atoms value to prevent writes
if (updateDataAtom) setUpdateDataAtom(RESET);
if (updateDataAtom) setUpdateDataAtom(undefined);
};
}, [
firebaseDb,

View File

@@ -27,7 +27,6 @@ export const ProjectSourceFirebase = memo(function ProjectSourceFirebase() {
// Also sets functions to update those documents.
useSettingsDocs();
useTableFunctions();
console.log("rerender");
return null;
});

View File

@@ -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<TableSchema["columns"]> =

View File

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

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

@@ -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 = {