add updateFieldAtom & id decrement

This commit is contained in:
Sidney Alcantara
2022-05-17 19:16:31 +10:00
parent 449467a7b6
commit daddc30861
11 changed files with 462 additions and 41 deletions

View File

@@ -1,5 +1,6 @@
import { renderHook, act } from "@testing-library/react";
import { useAtomValue, useSetAtom } from "jotai";
import { cloneDeep, unset } from "lodash-es";
import {
tableScope,
@@ -11,6 +12,7 @@ import {
} from "@src/atoms/tableScope";
import { TableSchema } from "@src/types/table";
import { FieldType } from "@src/constants/fields";
import { updateRowData } from "@src/utils/table";
const initUpdateTableSchemaAtom = (initialTableSchema?: TableSchema) =>
renderHook(() => {
@@ -18,9 +20,19 @@ const initUpdateTableSchemaAtom = (initialTableSchema?: TableSchema) =>
setTableSchema(initialTableSchema ?? {});
const setUpdateTableSchema = useSetAtom(updateTableSchemaAtom, tableScope);
setUpdateTableSchema(() => async (update: Partial<TableSchema>) => {
setTableSchema(update);
});
setUpdateTableSchema(
() => async (update: Partial<TableSchema>, deleteFields?: string[]) => {
setTableSchema((current) => {
const withFieldsDeleted = cloneDeep(current);
if (Array.isArray(deleteFields)) {
for (const field of deleteFields) {
unset(withFieldsDeleted, field);
}
}
return updateRowData(withFieldsDeleted || {}, update);
});
}
);
});
const GENERATED_COLUMNS_LENGTH = 10;

View File

@@ -130,5 +130,5 @@ export const deleteColumnAtom = atom(null, async (get, _set, key: string) => {
.filter((c) => c.key !== key)
.reduce(tableColumnsReducer, {});
await updateTableSchema({ columns: updatedColumns });
await updateTableSchema({ columns: updatedColumns }, [`columns.${key}`]);
});

View File

@@ -4,6 +4,7 @@ import { useAtomValue, useSetAtom } from "jotai";
import { useAtomCallback } from "jotai/utils";
import { find, findIndex, mergeWith, isArray } from "lodash-es";
import { currentUserAtom } from "@src/atoms/globalScope";
import {
tableScope,
tableSettingsAtom,
@@ -11,15 +12,24 @@ import {
tableRowsLocalAtom,
_updateRowDbAtom,
_deleteRowDbAtom,
addRowAtom,
deleteRowAtom,
} from "@src/atoms/tableScope";
import { TableRow } from "@src/types/table";
import { tableRowsAtom } from "./table";
import { updateRowData, decrementId } from "@src/utils/table";
const TEST_COLLECTION = "_testing";
const initRows = (initialRowsDb?: TableRow[], initialRowsLocal?: TableRow[]) =>
renderHook(async () => {
const setCurrentUser = useSetAtom(currentUserAtom, tableScope);
setCurrentUser({
uid: "TEST_USER",
displayName: "Test User",
email: "test@example.com",
} as any);
const setTableSettings = useSetAtom(tableSettingsAtom, tableScope);
setTableSettings({
id: TEST_COLLECTION,
@@ -43,23 +53,17 @@ const initRows = (initialRowsDb?: TableRow[], initialRowsLocal?: TableRow[]) =>
const setUpdateRowDb = useSetAtom(_updateRowDbAtom, tableScope);
setUpdateRowDb(() => async (path: string, update: Partial<TableRow>) => {
const rows = [...(await readRowsDb())];
const index = findIndex(rows, ["_rowy_ref.id", path]);
const index = findIndex(rows, ["_rowy_ref.path", path]);
// Append if not found
if (index === -1) {
setRowsDb((rows) => [
setRowsDb([
...rows,
{ ...update, _rowy_ref: { id: path, path: "TEST_COLLECTION" } },
{ ...update, _rowy_ref: { id: path.split("/").pop()!, path } },
]);
} 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)
);
rows[index] = updateRowData(rows[index], update);
setRowsDb(rows);
}
setRowsDb(rows);
return Promise.resolve();
});
@@ -89,6 +93,136 @@ const generatedRowsLocal = new Array(GENERATED_ROWS_LENGTH)
index: i,
}));
describe("addRow", () => {
test("adds a single row with pre-defined id", async () => {
initRows(generatedRows);
const {
result: { current: addRow },
} = renderHook(() => useSetAtom(addRowAtom, tableScope));
expect(addRow).toBeDefined();
await act(() =>
addRow({
row: {
_rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" },
added: true,
},
})
);
const {
result: { current: tableRows },
} = renderHook(() => useAtomValue(tableRowsAtom, tableScope));
expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH + 1);
expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeDefined();
expect(find(tableRows, ["_rowy_ref.id", "addedRow"])?.added).toBe(true);
});
test("adds a single row with pre-defined id to an empty table", async () => {
initRows();
const {
result: { current: addRow },
} = renderHook(() => useSetAtom(addRowAtom, tableScope));
expect(addRow).toBeDefined();
await act(() =>
addRow({
row: {
_rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" },
added: true,
},
})
);
const {
result: { current: tableRows },
} = renderHook(() => useAtomValue(tableRowsAtom, tableScope));
expect(tableRows).toHaveLength(1);
expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeDefined();
expect(find(tableRows, ["_rowy_ref.id", "addedRow"])?.added).toBe(true);
});
test("adds a single row and generate random id", async () => {
initRows(generatedRows);
const {
result: { current: addRow },
} = renderHook(() => useSetAtom(addRowAtom, tableScope));
expect(addRow).toBeDefined();
await act(() =>
addRow({
row: {
_rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" },
added: true,
},
setId: "random",
})
);
const {
result: { current: tableRows },
} = renderHook(() => useAtomValue(tableRowsAtom, tableScope));
expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH + 1);
expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeUndefined();
expect(find(tableRows, ["added", true])).toBeDefined();
expect(find(tableRows, ["added", true])?._rowy_ref.id).toHaveLength(20);
});
test("adds a single row and decrement id", async () => {
initRows(generatedRows);
const {
result: { current: addRow },
} = renderHook(() => useSetAtom(addRowAtom, tableScope));
expect(addRow).toBeDefined();
await act(() =>
addRow({
row: {
_rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" },
added: true,
},
setId: "decrement",
})
);
const {
result: { current: tableRows },
} = renderHook(() => useAtomValue(tableRowsAtom, tableScope));
expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH + 1);
expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeUndefined();
expect(find(tableRows, ["added", true])).toBeDefined();
expect(find(tableRows, ["added", true])?._rowy_ref.id).toBe(
decrementId("row0")
);
});
test("adds a single row with decrement id to an empty table", async () => {
initRows([]);
const {
result: { current: addRow },
} = renderHook(() => useSetAtom(addRowAtom, tableScope));
expect(addRow).toBeDefined();
await act(() =>
addRow({
row: {
_rowy_ref: { id: "addedRow", path: TEST_COLLECTION + "/addedRow" },
added: true,
},
setId: "decrement",
})
);
const {
result: { current: tableRows },
} = renderHook(() => useAtomValue(tableRowsAtom, tableScope));
expect(tableRows).toHaveLength(1);
expect(find(tableRows, ["_rowy_ref.id", "addedRow"])).toBeUndefined();
expect(find(tableRows, ["added", true])).toBeDefined();
expect(find(tableRows, ["added", true])?._rowy_ref.id).toBe(decrementId());
});
});
describe("deleteRow", () => {
test("deletes a single row", async () => {
initRows(generatedRows);
@@ -173,4 +307,19 @@ describe("deleteRow", () => {
} = renderHook(() => useAtomValue(tableRowsAtom, tableScope));
expect(tableRows).toHaveLength(GENERATED_ROWS_LENGTH);
});
test("doesn't delete from empty rows", async () => {
initRows();
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(0);
});
});

View File

@@ -1,5 +1,5 @@
import { atom } from "jotai";
import { find } from "lodash-es";
import { cloneDeep, find, set as _set, unset } from "lodash-es";
import { currentUserAtom } from "@src/atoms/globalScope";
import {
@@ -7,22 +7,30 @@ import {
tableSettingsAtom,
tableFiltersAtom,
tableRowsLocalAtom,
tableRowsAtom,
_updateRowDbAtom,
_deleteRowDbAtom,
} from "./table";
import { tableColumnsOrderedAtom } from "./columnActions";
import { TableRow } from "@src/types/table";
import { rowyUser } from "@src/utils/table";
import {
rowyUser,
generateId,
decrementId,
updateRowData,
} from "@src/utils/table";
export interface IAddRowOptions {
/** The row or array of rows to add */
row: TableRow | TableRow[];
/** If true, ignores checking required fields have values */
ignoreRequiredFields?: boolean;
/** Optionally overwite the IDs in the provided rows */
setId?: "random" | "decrement";
}
/**
* Adds a row or an array of rows.
* Adds to rowsDb if it has no missing required fields,
* otherwise to or rowsLocal.
* Adds to rowsDb if it has no missing required fields, otherwise to rowsLocal.
* @param options - {@link IAddRowOptions}
*
* @example Basic usage:
@@ -33,7 +41,7 @@ export interface IAddRowOptions {
*/
export const addRowAtom = atom(
null,
async (get, set, { row, ignoreRequiredFields }: IAddRowOptions) => {
async (get, set, { row, ignoreRequiredFields, setId }: IAddRowOptions) => {
const updateRowDb = get(_updateRowDbAtom);
if (!updateRowDb) throw new Error("Cannot write to database");
const tableSettings = get(tableSettingsAtom);
@@ -43,6 +51,7 @@ export const addRowAtom = atom(
const auditChange = get(auditChangeAtom);
const tableFilters = get(tableFiltersAtom);
const tableColumnsOrdered = get(tableColumnsOrderedAtom);
const tableRows = get(tableRowsAtom);
const _addSingleRowAndAudit = async (row: TableRow) => {
// Store initial values to be written
@@ -113,10 +122,43 @@ export const addRowAtom = atom(
};
if (Array.isArray(row)) {
const promises = row.map(_addSingleRowAndAudit);
const promises: Promise<void>[] = [];
let lastId = tableRows[0]?._rowy_ref.id;
for (const r of row) {
const id =
setId === "random"
? generateId()
: setId === "decrement"
? decrementId(lastId)
: r._rowy_ref.id;
lastId = id;
const path = setId
? `${r._rowy_ref.path.split("/").slice(0, -1).join("/")}/${id}`
: r._rowy_ref.path;
promises.push(
_addSingleRowAndAudit(setId ? { ...r, _rowy_ref: { id, path } } : r)
);
}
await Promise.all(promises);
} else {
await _addSingleRowAndAudit(row);
const id =
setId === "random"
? generateId()
: setId === "decrement"
? decrementId(tableRows[0]?._rowy_ref.id)
: row._rowy_ref.id;
const path = setId
? `${row._rowy_ref.path.split("/").slice(0, -1).join("/")}/${id}`
: row._rowy_ref.path;
await _addSingleRowAndAudit(
setId ? { ...row, _rowy_ref: { id, path } } : row
);
}
}
);
@@ -138,10 +180,12 @@ export const deleteRowAtom = atom(
if (!deleteRowDb) throw new Error("Cannot write to database");
const auditChange = get(auditChangeAtom);
const rowsLocal = get(tableRowsLocalAtom);
const tableRowsLocal = get(tableRowsLocalAtom);
const _deleteSingleRowAndAudit = async (path: string) => {
const isLocalRow = Boolean(find(rowsLocal, ["_rowy_ref.path", path]));
const isLocalRow = Boolean(
find(tableRowsLocal, ["_rowy_ref.path", path])
);
if (isLocalRow) set(tableRowsLocalAtom, { type: "delete", path });
else await deleteRowDb(path);
if (auditChange) auditChange("DELETE_ROW", path);
@@ -155,3 +199,107 @@ export const deleteRowAtom = atom(
}
}
);
export interface IUpdateFieldOptions {
/** The path to the row to update */
path: string;
/** The field name to update. Use dot notation to access nested fields. */
fieldName: string;
/** The value to write */
value: any;
/** Optionally, delete the field with fieldName. Use dot notation to access nested fields. */
deleteField?: boolean;
/** If true, ignores checking required fields have values */
ignoreRequiredFields?: boolean;
}
/**
* Updates or deletes a field in a row.
* Adds to rowsDb if it has no missing required fields,
* otherwise keeps in rowsLocal.
* @param options - {@link IAddRowOptions}
*
* @example Basic usage:
* ```
* const updateField = useSetAtom(updateFieldAtom, tableScope);
* updateField({ path, fieldName: "", value: null, deleteField: true });
* ```
*/
export const updateFieldAtom = atom(
null,
async (
get,
set,
{
path,
fieldName,
value,
deleteField,
ignoreRequiredFields,
}: IUpdateFieldOptions
) => {
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 tableColumnsOrdered = get(tableColumnsOrderedAtom);
const tableRows = get(tableRowsAtom);
const tableRowsLocal = get(tableRowsLocalAtom);
const row = find(tableRows, ["_rowy_ref.path", path]);
if (!row) throw new Error("Could not find row");
const isLocalRow = Boolean(find(tableRowsLocal, ["_rowy_ref.path", path]));
const update: Partial<TableRow> = {};
// Write audit fields if not explicitly disabled
if (tableSettings.audit !== false) {
const auditValue = rowyUser(currentUser);
update[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);
// Apply field update
if (!deleteField) _set(update, fieldName, value);
// Update rowsLocal if any required fields are missing
if (missingRequiredFields.length > 0) {
set(tableRowsLocalAtom, {
type: "update",
path,
row: update,
deleteFields: deleteField ? [fieldName] : [],
});
}
// If no required fields are missing and the row is only local,
// write the entire row to the database
else if (isLocalRow) {
const rowValues = updateRowData(cloneDeep(row), update);
if (deleteField) unset(rowValues, fieldName);
await updateRowDb(
row._rowy_ref.path,
rowValues,
deleteField ? [fieldName] : []
);
}
// Otherwise, update single field in database
else {
await updateRowDb(path, update, deleteField ? [fieldName] : []);
}
if (auditChange)
auditChange("UPDATE_CELL", path, { updatedField: fieldName });
}
);

View File

@@ -1,6 +1,6 @@
import { atom } from "jotai";
import { atomWithReducer } from "jotai/utils";
import { uniqBy, findIndex } from "lodash-es";
import { uniqBy, findIndex, cloneDeep, unset } from "lodash-es";
import {
TableSettings,
@@ -8,6 +8,7 @@ import {
TableFilter,
TableOrder,
TableRow,
UpdateDocFunction,
UpdateCollectionDocFunction,
DeleteCollectionDocFunction,
} from "@src/types/table";
@@ -18,10 +19,10 @@ 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);
export const tableSchemaAtom = atom<TableSchema>({});
/** Store function to update tableSchema */
export const updateTableSchemaAtom = atom<
((update: Partial<TableSchema>) => Promise<void>) | undefined
UpdateDocFunction<TableSchema> | undefined
>(undefined);
/** Filters applied to the local view */
@@ -37,7 +38,12 @@ type TableRowsLocalAction =
/** Add a row or multiple rows */
| { type: "add"; row: TableRow | TableRow[] }
/** Update a row */
| { type: "update"; path: string; row: Partial<TableRow> }
| {
type: "update";
path: string;
row: Partial<TableRow>;
deleteFields?: string[];
}
/** Delete a row or multiple rows */
| { type: "delete"; path: string | string[] };
const tableRowsLocalReducer = (
@@ -55,7 +61,13 @@ const tableRowsLocalReducer = (
const index = findIndex(prev, ["_rowy_ref.path", action.path]);
if (index > -1) {
const updatedRows = [...prev];
updatedRows[index] = updateRowData(prev[index], action.row);
if (Array.isArray(action.deleteFields)) {
updatedRows[index] = cloneDeep(prev[index]);
for (const field of action.deleteFields) {
unset(updatedRows[index], field);
}
}
updatedRows[index] = updateRowData(updatedRows[index], action.row);
return updatedRows;
}
// If not found, add to start

View File

@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useAtom, PrimitiveAtom, useSetAtom } from "jotai";
import { Scope } from "jotai/core/atom";
import { set } from "lodash-es";
import {
query,
collection,
@@ -15,6 +16,7 @@ import {
deleteDoc,
CollectionReference,
Query,
deleteField,
} from "firebase/firestore";
import { useErrorHandler } from "react-error-boundary";
@@ -154,8 +156,17 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
// set the atoms value to a function that updates a doc in the collection
if (updateDocAtom) {
setUpdateDocAtom(
() => (path: string, update: T) =>
setDoc(doc(firebaseDb, path), update, { merge: true })
() => (path: string, update: T, deleteFields?: string[]) => {
const updateToDb = { ...update };
if (Array.isArray(deleteFields)) {
for (const field of deleteFields) {
set(updateToDb as any, field, deleteField());
}
}
return setDoc(doc(firebaseDb, path), updateToDb, { merge: true });
}
);
}

View File

@@ -1,12 +1,14 @@
import { useEffect } from "react";
import { useAtom, PrimitiveAtom, useSetAtom } from "jotai";
import { Scope } from "jotai/core/atom";
import { set } from "lodash-es";
import {
doc,
onSnapshot,
FirestoreError,
setDoc,
DocumentReference,
deleteField,
} from "firebase/firestore";
import { useErrorHandler } from "react-error-boundary";
@@ -108,9 +110,17 @@ export function useFirestoreDocWithAtom<T = TableRow>(
// If `options?.updateDataAtom` was passed,
// set the atoms value to a function that updates the document
if (updateDataAtom) {
setUpdateDataAtom(
() => (update: T) => setDoc(ref, update, { merge: true })
);
setUpdateDataAtom(() => (update: T, deleteFields?: string[]) => {
const updateToDb = { ...update };
if (Array.isArray(deleteFields)) {
for (const field of deleteFields) {
set(updateToDb as any, field, deleteField());
}
}
return setDoc(ref, updateToDb, { merge: true });
});
}
return () => {

View File

@@ -33,7 +33,7 @@ function TableTestPage() {
const [tableRows] = useAtom(tableRowsAtom, tableScope);
const [auditChange] = useAtom(auditChangeAtom, tableScope);
console.log(tableId, tableSchema);
console.log(tableId, tableSettings, tableSchema);
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);

View File

@@ -13,6 +13,7 @@ import {
tableIdAtom,
tableSettingsAtom,
tableSchemaAtom,
updateTableSchemaAtom,
tableFiltersAtom,
tableOrdersAtom,
tablePageAtom,
@@ -63,7 +64,11 @@ const TableSourceFirestore = memo(function TableSourceFirestore() {
tableSchemaAtom,
tableScope,
isCollectionGroup ? TABLE_GROUP_SCHEMAS : TABLE_SCHEMAS,
{ pathSegments: [tableId], createIfNonExistent: { columns: {} } }
{
pathSegments: [tableId],
createIfNonExistent: { columns: {} },
updateDataAtom: updateTableSchemaAtom,
}
);
// Get table filters and orders

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

@@ -5,15 +5,35 @@ import type {
DocumentReference,
} from "firebase/firestore";
/**
* A standard function to update a doc in the database
* @param update - The updates to be deeply merged with the existing doc. Note arrays should be ovewritten to match Firestore set with merge behavior
* @param deleteFields - Optionally, fields to be deleted from the doc. Access nested fields with dot notation
* @returns Promise when complete
*/
export type UpdateDocFunction<T = TableRow> = (
update: Partial<T>
update: Partial<T>,
deleteFields?: string[]
) => Promise<void>;
/**
* A standard function to update a doc in a specific collection in the database
* @param path - The full path to the doc
* @param update - The updates to be deeply merged with the existing doc. Note arrays should be ovewritten to match Firestore set with merge behavior
* @param deleteFields - Optionally, fields to be deleted from the doc. Access nested fields with dot notation
* @returns Promise when complete
*/
export type UpdateCollectionDocFunction<T = TableRow> = (
path: string,
update: Partial<T>
update: Partial<T>,
deleteFields?: string[]
) => Promise<void>;
/**
* A standard function to delete a doc in a specific collection in the database
* @param path - The full path to the doc
* @returns Promise when complete
*/
export type DeleteCollectionDocFunction = (path: string) => Promise<void>;
/** Table settings stored in project settings */

View File

@@ -1,6 +1,5 @@
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
@@ -32,10 +31,65 @@ export const rowyUser = (currentUser: User, data?: Record<string, any>) => {
* @param update - The partial update to apply
* @returns The row with updated values
*/
export const updateRowData = (row: TableRow, update: Partial<TableRow>) =>
export const updateRowData = <T = Record<string, any>>(
row: T,
update: Partial<T>
): T =>
mergeWith(
row,
update,
// If the proeprty to be merged is array, overwrite the array entirely
(objValue, srcValue) => (isArray(objValue) ? srcValue : undefined)
);
const ID_CHARACTERS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/**
* Generate an ID compatible with Firestore
* @param length - The length of the ID to generate
* @returns - Generated ID
*/
export const generateId = (length: number = 20) => {
let result = "";
const charactersLength = ID_CHARACTERS.length;
for (var i = 0; i < length; i++)
result += ID_CHARACTERS.charAt(
Math.floor(Math.random() * charactersLength)
);
return result;
};
/**
* Lexicographically decrement a given ID
* @param id - The ID to decrement. If not provided, set to 20 `z` characters
* @returns - The decremented ID
*/
export const decrementId = (id: string = "zzzzzzzzzzzzzzzzzzzz") => {
const newId = id.split("");
// Loop through ID characters from the end
let i = newId.length - 1;
while (i > -1) {
const newCharacterIndex = ID_CHARACTERS.indexOf(newId[i]) - 1;
newId[i] =
ID_CHARACTERS[
newCharacterIndex > -1 ? newCharacterIndex : ID_CHARACTERS.length - 1
];
// If we dont hit 0, were done
if (newCharacterIndex > -1) break;
// Otherwise, if we hit 0, we need to decrement the next character
i--;
}
// Ensure we don't get 00...0, then the next ID would be 00...0z,
// which would appear as the second row
if (newId.every((x) => x === ID_CHARACTERS[0]))
newId.push(ID_CHARACTERS[ID_CHARACTERS.length - 1]);
return newId.join("");
};