support sub-tables with nested routes & jotai scoped providers

This commit is contained in:
Sidney Alcantara
2022-06-09 15:22:14 +10:00
parent 3e791294fe
commit 94c227cdd7
16 changed files with 550 additions and 321 deletions

View File

@@ -41,7 +41,9 @@ const TableSettingsDialog = lazy(() => import("@src/components/TableSettingsDial
// prettier-ignore
const TablesPage = lazy(() => import("@src/pages/TablesPage" /* webpackChunkName: "TablesPage" */));
// prettier-ignore
const TablePage = lazy(() => import("@src/pages/TablePage" /* webpackChunkName: "TablePage" */));
const ProvidedTablePage = lazy(() => import("@src/pages/Table/ProvidedTablePage" /* webpackChunkName: "ProvidedTablePage" */));
// prettier-ignore
const ProvidedSubTablePage = lazy(() => import("@src/pages/Table/ProvidedSubTablePage" /* webpackChunkName: "ProvidedSubTablePage" */));
// prettier-ignore
const FunctionPage = lazy(() => import("@src/pages/FunctionPage" /* webpackChunkName: "FunctionPage" */));
@@ -105,8 +107,17 @@ export default function App() {
<Route path={ROUTES.table}>
<Route index element={<Navigate to={ROUTES.tables} replace />} />
<Route path=":id" element={<TablePage />} />
<Route path=":id" element={<ProvidedTablePage />}>
<Route path={ROUTES.subTable}>
<Route index element={<NotFound />} />
<Route
path=":docPath/:subTableKey"
element={<ProvidedSubTablePage />}
/>
</Route>
</Route>
</Route>
<Route path={ROUTES.tableGroup}>
<Route index element={<Navigate to={ROUTES.tables} replace />} />
<Route path=":id" element={<TableGroupRedirectPage />} />

View File

@@ -10,10 +10,11 @@ import { Tables as TablesIcon } from "@src/assets/icons";
import EmptyState, { IEmptyStateProps } from "@src/components/EmptyState";
import AccessDenied from "@src/components/AccessDenied";
import { ERROR_TABLE_NOT_FOUND } from "@src/sources/TableSourceFirestore";
import { ROUTES } from "@src/constants/routes";
import meta from "@root/package.json";
export const ERROR_TABLE_NOT_FOUND = "Table not found";
export interface IErrorFallbackProps extends FallbackProps, IEmptyStateProps {}
export default function ErrorFallback({
@@ -54,12 +55,13 @@ export default function ErrorFallback({
),
};
if (error.message === ERROR_TABLE_NOT_FOUND) {
if (error.message.startsWith(ERROR_TABLE_NOT_FOUND)) {
renderProps = {
message: "Table not found",
message: ERROR_TABLE_NOT_FOUND,
description: (
<>
<span>Make sure you have the right ID</span>
<code>{error.message.replace(ERROR_TABLE_NOT_FOUND + ": ", "")}</code>
<Button
size={props.basic ? "small" : "medium"}
variant="outlined"

View File

@@ -99,7 +99,11 @@ export default function Modal({
: props.sx
}
>
<Stack direction="row" alignItems="flex-start">
<Stack
direction="row"
alignItems="flex-start"
className="modal-title-row"
>
<DialogTitle
id="modal-title"
style={{ flexGrow: 1, userSelect: "none" }}

View File

@@ -20,7 +20,7 @@ export default function TableSettings() {
openTableSettingsDialog({ mode: "update", data: tableSettings })
}
icon={<SettingsIcon />}
disabled={!openTableSettingsDialog}
disabled={!openTableSettingsDialog || tableSettings.id.includes("/")}
/>
);
}

View File

@@ -1,4 +1,4 @@
import { useLocation, useSearchParams } from "react-router-dom";
import { useLocation } from "react-router-dom";
import { ROUTES } from "@src/constants/routes";
import { ColumnConfig, TableRow, TableRowRef } from "@src/types/table";
@@ -13,24 +13,24 @@ export const useSubTableData = (
else return row[curr];
}, "");
const fieldName = column.key;
const documentCount: string = row[fieldName]?.count ?? "";
const documentCount: string = row[column.fieldName]?.count ?? "";
const location = useLocation();
const parentPath = decodeURIComponent(
location.pathname.split("/").pop() ?? ""
const rootTablePath = decodeURIComponent(
location.pathname.split("/" + ROUTES.subTable)[0]
);
const [searchParams] = useSearchParams();
const parentLabels = searchParams.get("parentLabel");
let subTablePath =
ROUTES.table +
"/" +
encodeURIComponent(`${parentPath}/${docRef.id}/${fieldName}`) +
"?parentLabel=";
// const [searchParams] = useSearchParams();
// const parentLabels = searchParams.get("parentLabel");
let subTablePath = [
rootTablePath,
ROUTES.subTable,
encodeURIComponent(docRef.path),
column.key,
].join("/");
if (parentLabels) subTablePath += `${parentLabels ?? ""},${label ?? ""}`;
else subTablePath += encodeURIComponent(label ?? "");
// if (parentLabels) subTablePath += `${parentLabels ?? ""},${label ?? ""}`;
// else subTablePath += encodeURIComponent(label ?? "");
return { documentCount, label, subTablePath };
};

View File

@@ -23,7 +23,13 @@ export enum ROUTES {
table = "/table",
tableWithId = "/table/:id",
/** Nested route: `/table/:id/subTable/...` */
subTable = "subTable",
/** Nested route: `/table/:id/subTable/...` */
subTableWithId = "subTable/:docPath/:subTableKey",
/** @deprecated Redirects to /table */
tableGroup = "/tableGroup",
/** @deprecated Redirects to /table */
tableGroupWithId = "/tableGroup/:id",
settings = "/settings",

View File

@@ -0,0 +1,127 @@
import { Suspense, useMemo } from "react";
import { useAtom, Provider } from "jotai";
import { selectAtom } from "jotai/utils";
import { DebugAtoms } from "@src/atoms/utils";
import { ErrorBoundary } from "react-error-boundary";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { find, isEqual } from "lodash-es";
import Modal from "@src/components/Modal";
import Breadcrumbs from "@src/components/Table/Breadcrumbs";
import ErrorFallback from "@src/components/ErrorFallback";
import TableSourceFirestore from "@src/sources/TableSourceFirestore";
import TablePage from "./TablePage";
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
import HeaderRowSkeleton from "@src/components/Table/HeaderRowSkeleton";
import { globalScope, currentUserAtom } from "@src/atoms/globalScope";
import {
tableScope,
tableIdAtom,
tableSettingsAtom,
tableSchemaAtom,
} from "@src/atoms/tableScope";
import { ROUTES } from "@src/constants/routes";
import { APP_BAR_HEIGHT } from "@src/layouts/Navigation";
/**
* Wraps `TablePage` with the data for a top-level table.
*/
export default function ProvidedSubTablePage() {
const location = useLocation();
const navigate = useNavigate();
// Get params from URL: /subTable/:docPath/:subTableKey
const { docPath, subTableKey } = useParams();
const [currentUser] = useAtom(currentUserAtom, globalScope);
// Get table settings and the source column from root table
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const [sourceColumn] = useAtom(
useMemo(
() =>
selectAtom(
tableSchemaAtom,
(tableSchema) => find(tableSchema.columns, ["key", subTableKey]),
isEqual
),
[subTableKey]
),
tableScope
);
// Consumed by children as `tableSettings.collection`
const subTableCollection =
docPath + "/" + (sourceColumn?.fieldName || subTableKey);
// Must be compatible with `getTableSchemaPath`: tableId/rowId/subTableKey
// This is why we cant have a sub-table column fieldName !== key
const subTableId =
docPath?.replace(tableSettings.collection, tableSettings.id) +
"/" +
subTableKey;
// Write fake tableSettings
const subTableSettings = {
...tableSettings,
collection: subTableCollection,
id: subTableId,
tableType: "primaryCollection" as "primaryCollection",
name: sourceColumn?.name || subTableKey,
};
return (
<Modal
title={<>Sub-table: {subTableCollection}</>}
onClose={() =>
navigate(location.pathname.split("/" + ROUTES.subTable)[0])
}
disableBackdropClick
disableEscapeKeyDown
fullScreen
sx={{
"& > .MuiDialog-container > .MuiPaper-root": {
bgcolor: "background.default",
},
"& .modal-title-row": {
height: APP_BAR_HEIGHT,
"& .MuiDialogTitle-root": {
px: 2,
py: (APP_BAR_HEIGHT - 28) / 2 / 8,
},
"& .dialog-close": { m: (APP_BAR_HEIGHT - 40) / 2 / 8, ml: -1 },
},
}}
ScrollableDialogContentProps={{
disableTopDivider: true,
disableBottomDivider: true,
style: { "--dialog-spacing": 0, "--dialog-contents-spacing": 0 } as any,
}}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense
fallback={
<>
<TableToolbarSkeleton />
<HeaderRowSkeleton />
</>
}
>
<Provider
key={tableScope.description + "/subTable/" + subTableSettings.id}
scope={tableScope}
initialValues={[
[currentUserAtom, currentUser],
[tableIdAtom, subTableSettings.id],
[tableSettingsAtom, subTableSettings],
]}
>
<DebugAtoms scope={tableScope} />
<TableSourceFirestore />
<TablePage />
</Provider>
</Suspense>
</ErrorBoundary>
</Modal>
);
}

View File

@@ -0,0 +1,70 @@
import { Suspense, useMemo } from "react";
import { useAtom, Provider } from "jotai";
import { DebugAtoms } from "@src/atoms/utils";
import { useParams, Outlet } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { find } from "lodash-es";
import ErrorFallback, {
ERROR_TABLE_NOT_FOUND,
} from "@src/components/ErrorFallback";
import TableSourceFirestore from "@src/sources/TableSourceFirestore";
import TablePage from "./TablePage";
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
import HeaderRowSkeleton from "@src/components/Table/HeaderRowSkeleton";
import {
globalScope,
currentUserAtom,
tablesAtom,
} from "@src/atoms/globalScope";
import {
tableScope,
tableIdAtom,
tableSettingsAtom,
} from "@src/atoms/tableScope";
/**
* Wraps `TablePage` with the data for a top-level table.
* `SubTablePage` is inserted in the outlet, alongside `TablePage`.
*/
export default function ProvidedTablePage() {
const { id } = useParams();
const [currentUser] = useAtom(currentUserAtom, globalScope);
const [tables] = useAtom(tablesAtom, globalScope);
const tableSettings = useMemo(() => find(tables, ["id", id]), [tables, id]);
if (!tableSettings) throw new Error(ERROR_TABLE_NOT_FOUND + ": " + id);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense
fallback={
<>
<TableToolbarSkeleton />
<HeaderRowSkeleton />
</>
}
>
<Provider
key={tableScope.description + "/" + id}
scope={tableScope}
initialValues={[
[currentUserAtom, currentUser],
[tableIdAtom, id],
[tableSettingsAtom, tableSettings],
]}
>
<DebugAtoms scope={tableScope} />
<TableSourceFirestore />
<main>
<TablePage />
</main>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</Provider>
</Suspense>
</ErrorBoundary>
);
}

View File

@@ -1,12 +1,13 @@
import { useRef, Suspense, lazy } from "react";
import { useAtom, Provider } from "jotai";
import { DebugAtoms } from "@src/atoms/utils";
import { useParams } from "react-router-dom";
import { useAtom } from "jotai";
import { DataGridHandle } from "react-data-grid";
import { ErrorBoundary } from "react-error-boundary";
import { isEmpty } from "lodash-es";
import { Fade } from "@mui/material";
import ErrorFallback, {
InlineErrorFallback,
} from "@src/components/ErrorFallback";
import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton";
import HeaderRowSkeleton from "@src/components/Table/HeaderRowSkeleton";
import EmptyTable from "@src/components/Table/EmptyTable";
@@ -17,11 +18,8 @@ import ColumnMenu from "@src/components/ColumnMenu";
import ColumnModals from "@src/components/ColumnModals";
import TableModals from "@src/components/TableModals";
import { currentUserAtom, globalScope } from "@src/atoms/globalScope";
import TableSourceFirestore from "@src/sources/TableSourceFirestore";
import {
tableScope,
tableIdAtom,
tableSchemaAtom,
columnModalAtom,
tableModalAtom,
@@ -33,7 +31,11 @@ import { useSnackLogContext } from "@src/contexts/SnackLogContext";
// prettier-ignore
const BuildLogsSnack = lazy(() => import("@src/components/TableModals/CloudLogsModal/BuildLogs/BuildLogsSnack" /* webpackChunkName: "TableModals-BuildLogsSnack" */));
function TablePage() {
/**
* TablePage renders all the UI for the table.
* Must be wrapped by either `ProvidedTablePage` or `ProvidedSubTablePage`.
*/
export default function TablePage() {
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
const snackLogContext = useSnackLogContext();
@@ -65,65 +67,44 @@ function TablePage() {
return (
<ActionParamsProvider>
<Suspense fallback={<TableToolbarSkeleton />}>
<TableToolbar />
</Suspense>
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={<TableToolbarSkeleton />}>
<TableToolbar />
</Suspense>
</ErrorBoundary>
<Suspense fallback={<HeaderRowSkeleton />}>
<Table dataGridRef={dataGridRef} />
</Suspense>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<HeaderRowSkeleton />}>
<Table dataGridRef={dataGridRef} />
</Suspense>
</ErrorBoundary>
<Suspense fallback={null}>
<SideDrawer dataGridRef={dataGridRef} />
</Suspense>
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={null}>
<SideDrawer dataGridRef={dataGridRef} />
</Suspense>
</ErrorBoundary>
<Suspense fallback={null}>
<ColumnMenu />
<ColumnModals />
</Suspense>
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={null}>
<ColumnMenu />
<ColumnModals />
</Suspense>
</ErrorBoundary>
<Suspense fallback={null}>
<TableModals />
{snackLogContext.isSnackLogOpen && (
<Suspense fallback={null}>
<BuildLogsSnack
onClose={snackLogContext.closeSnackLog}
onOpenPanel={alert}
/>
</Suspense>
)}
</Suspense>
<ErrorBoundary FallbackComponent={InlineErrorFallback}>
<Suspense fallback={null}>
<TableModals />
{snackLogContext.isSnackLogOpen && (
<Suspense fallback={null}>
<BuildLogsSnack
onClose={snackLogContext.closeSnackLog}
onOpenPanel={alert}
/>
</Suspense>
)}
</Suspense>
</ErrorBoundary>
</ActionParamsProvider>
);
}
export default function ProvidedTablePage() {
const { id } = useParams();
const [currentUser] = useAtom(currentUserAtom, globalScope);
return (
<Suspense
fallback={
<>
<TableToolbarSkeleton />
<HeaderRowSkeleton />
</>
}
>
<Provider
key={tableScope.description + "/" + id}
scope={tableScope}
initialValues={[
[tableIdAtom, id],
[currentUserAtom, currentUser],
]}
>
<DebugAtoms scope={tableScope} />
<TableSourceFirestore />
<main>
<TablePage />
</main>
</Provider>
</Suspense>
);
}

View File

@@ -1,237 +0,0 @@
import { memo, useMemo, useEffect, useCallback } from "react";
import { useAtom, useSetAtom } from "jotai";
import { find, chunk, set } from "lodash-es";
import { doc, writeBatch, deleteField } from "firebase/firestore";
import {
globalScope,
tablesAtom,
rowyRunAtom,
compatibleRowyRunVersionAtom,
} from "@src/atoms/globalScope";
import {
tableScope,
tableIdAtom,
tableSettingsAtom,
tableSchemaAtom,
updateTableSchemaAtom,
tableFiltersAtom,
tableOrdersAtom,
tablePageAtom,
tableRowsDbAtom,
_updateRowDbAtom,
_deleteRowDbAtom,
_bulkWriteDbAtom,
tableNextPageAtom,
auditChangeAtom,
} from "@src/atoms/tableScope";
import { BulkWriteFunction } from "@src/types/table";
import { firebaseDbAtom } from "./ProjectSourceFirebase";
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom";
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";
export const ERROR_TABLE_NOT_FOUND = "Table not found";
const TableSourceFirestore = memo(function TableSourceFirestore() {
const [tables] = useAtom(tablesAtom, globalScope);
// Get tableSettings from tableId and tables in globalScope
const [tableId] = useAtom(tableIdAtom, tableScope);
const setTableSettings = useSetAtom(tableSettingsAtom, tableScope);
// Store tableSettings as local const so we dont re-render
// when tableSettingsAtom is set
const tableSettings = useMemo(() => {
const match = find(tables, ["id", tableId]);
// Store in tableSettingsAtom
if (match) setTableSettings(match);
return match;
}, [tables, tableId, setTableSettings]);
if (!tableSettings) throw new Error(ERROR_TABLE_NOT_FOUND);
const isCollectionGroup = tableSettings?.tableType === "collectionGroup";
// Get tableSchema and store in tableSchemaAtom.
// If it doesnt exist, initialize columns
useFirestoreDocWithAtom(
tableSchemaAtom,
tableScope,
isCollectionGroup ? TABLE_GROUP_SCHEMAS : TABLE_SCHEMAS,
{
pathSegments: [tableId],
createIfNonExistent: { columns: {} },
updateDataAtom: updateTableSchemaAtom,
}
);
// Get table filters and orders
const [filters] = useAtom(tableFiltersAtom, tableScope);
const [orders] = useAtom(tableOrdersAtom, tableScope);
const [page] = useAtom(tablePageAtom, tableScope);
// Get documents from collection and store in tableRowsDbAtom
// and handle some errors with snackbars
const { enqueueSnackbar } = useSnackbar();
const elevateError = useErrorHandler();
const handleErrorCallback = useCallback(
(error: FirestoreError) =>
handleFirestoreError(error, enqueueSnackbar, elevateError),
[enqueueSnackbar, elevateError]
);
useFirestoreCollectionWithAtom(
tableRowsDbAtom,
tableScope,
tableSettings?.collection,
{
filters,
orders,
page,
collectionGroup: isCollectionGroup,
onError: handleErrorCallback,
updateDocAtom: _updateRowDbAtom,
deleteDocAtom: _deleteRowDbAtom,
nextPageAtom: tableNextPageAtom,
}
);
// 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,
},
}).catch(console.log)
);
return () => setAuditChange(undefined);
}, [setAuditChange, rowyRun, compatibleRowyRunVersion, tableSettings]);
// Set _bulkWriteDb function
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
const setBulkWriteDb = useSetAtom(_bulkWriteDbAtom, tableScope);
useEffect(() => {
setBulkWriteDb(
() =>
async (
operations: Parameters<BulkWriteFunction>[0],
onBatchCommit: Parameters<BulkWriteFunction>[1]
) => {
// Chunk operations into batches of 500 (Firestore limit is 500)
const operationsChunked = chunk(operations, 500);
// Loop through chunks of 500, then commit the batch sequentially
for (const [index, operationsChunk] of operationsChunked.entries()) {
// Create Firestore batch transaction
const batch = writeBatch(firebaseDb);
// Loop through operations and write to batch
for (const operation of operationsChunk) {
// New document
if (operation.type === "add") {
batch.set(doc(firebaseDb, operation.path), operation.data);
}
// Update existing document and merge values and delete fields
else if (operation.type === "update") {
const updateToDb = { ...operation.data };
if (Array.isArray(operation.deleteFields)) {
for (const field of operation.deleteFields) {
set(updateToDb as any, field, deleteField());
}
}
batch.set(doc(firebaseDb, operation.path), operation.data, {
merge: true,
});
}
// Delete existing documents
else if (operation.type === "delete") {
batch.delete(doc(firebaseDb, operation.path));
}
}
// Commit batch and wait for it to finish before continuing
// to prevent Firestore rate limits
await batch.commit().then(() => console.log("Batch committed"));
if (onBatchCommit) onBatchCommit(index + 1);
}
}
);
return () => setBulkWriteDb(undefined);
}, [firebaseDb, setBulkWriteDb]);
return null;
});
const handleFirestoreError = (
error: FirestoreError,
enqueueSnackbar: ReturnType<typeof useSnackbar>["enqueueSnackbar"],
elevateError: (error: FirestoreError) => void
) => {
if (error.code === "permission-denied") {
enqueueSnackbar("You do not have access to this table", {
variant: "error",
});
return;
}
if (error.message.includes("indexes?create_composite=")) {
enqueueSnackbar(
"Filtering while having another column sorted requires a new Firestore index",
{
variant: "warning",
action: (
<Button
variant="contained"
color="secondary"
href={"https" + error.message.split("https").pop()}
target="_blank"
rel="noopener noreferrer"
>
Create index
<InlineOpenInNewIcon style={{ lineHeight: "16px" }} />
</Button>
),
}
);
return;
}
elevateError(error);
};
export default TableSourceFirestore;

View File

@@ -0,0 +1,84 @@
import { memo, useCallback } from "react";
import { useAtom } from "jotai";
import { FirestoreError } from "firebase/firestore";
import {
tableScope,
tableSettingsAtom,
tableSchemaAtom,
updateTableSchemaAtom,
tableFiltersAtom,
tableOrdersAtom,
tablePageAtom,
tableRowsDbAtom,
_updateRowDbAtom,
_deleteRowDbAtom,
tableNextPageAtom,
} from "@src/atoms/tableScope";
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom";
import useAuditChange from "./useAuditChange";
import useBulkWriteDb from "./useBulkWriteDb";
import { handleFirestoreError } from "./handleFirestoreError";
import { useSnackbar } from "notistack";
import { useErrorHandler } from "react-error-boundary";
import { getTableSchemaPath } from "@src/utils/table";
/**
* When rendered, provides atom values for top-level tables and sub-tables
*/
export const TableSourceFirestore = memo(function TableSourceFirestore() {
// Get tableSettings from tableId and tables in globalScope
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
const isCollectionGroup = tableSettings?.tableType === "collectionGroup";
// Get tableSchema and store in tableSchemaAtom.
// If it doesnt exist, initialize columns
useFirestoreDocWithAtom(
tableSchemaAtom,
tableScope,
getTableSchemaPath(tableSettings),
{
createIfNonExistent: { columns: {} },
updateDataAtom: updateTableSchemaAtom,
}
);
// Get table filters and orders
const [filters] = useAtom(tableFiltersAtom, tableScope);
const [orders] = useAtom(tableOrdersAtom, tableScope);
const [page] = useAtom(tablePageAtom, tableScope);
// Get documents from collection and store in tableRowsDbAtom
// and handle some errors with snackbars
const { enqueueSnackbar } = useSnackbar();
const elevateError = useErrorHandler();
const handleErrorCallback = useCallback(
(error: FirestoreError) =>
handleFirestoreError(error, enqueueSnackbar, elevateError),
[enqueueSnackbar, elevateError]
);
useFirestoreCollectionWithAtom(
tableRowsDbAtom,
tableScope,
tableSettings?.collection,
{
filters,
orders,
page,
collectionGroup: isCollectionGroup,
onError: handleErrorCallback,
updateDocAtom: _updateRowDbAtom,
deleteDocAtom: _deleteRowDbAtom,
nextPageAtom: tableNextPageAtom,
}
);
useAuditChange();
useBulkWriteDb();
return null;
});
export default TableSourceFirestore;

View File

@@ -0,0 +1,48 @@
import { FirestoreError } from "firebase/firestore";
import { useSnackbar } from "notistack";
import { Button } from "@mui/material";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
/**
* Handles errors in Firestore listeners in the UI
* @param error - FirestoreError
* @param enqueueSnackbar - Displays snackbar error
* @param elevateError - Displays error message in ErrorBoundary
*/
export const handleFirestoreError = (
error: FirestoreError,
enqueueSnackbar: ReturnType<typeof useSnackbar>["enqueueSnackbar"],
elevateError: (error: FirestoreError) => void
) => {
if (error.code === "permission-denied") {
enqueueSnackbar("You do not have access to this table", {
variant: "error",
});
return;
}
if (error.message.includes("indexes?create_composite=")) {
enqueueSnackbar(
"Filtering while having another column sorted requires a new Firestore index",
{
variant: "warning",
action: (
<Button
variant="contained"
color="secondary"
href={"https" + error.message.split("https").pop()}
target="_blank"
rel="noopener noreferrer"
>
Create index
<InlineOpenInNewIcon style={{ lineHeight: "16px" }} />
</Button>
),
}
);
return;
}
elevateError(error);
};

View File

@@ -0,0 +1,2 @@
export * from "./TableSourceFirestore";
export { default } from "./TableSourceFirestore";

View File

@@ -0,0 +1,63 @@
import { useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import {
globalScope,
rowyRunAtom,
compatibleRowyRunVersionAtom,
} from "@src/atoms/globalScope";
import {
tableScope,
tableSettingsAtom,
auditChangeAtom,
} from "@src/atoms/tableScope";
import { runRoutes } from "@src/constants/runRoutes";
/**
* Sets the value of auditChangeAtom
*/
export default function useAuditChange() {
const setAuditChange = useSetAtom(auditChangeAtom, tableScope);
const [rowyRun] = useAtom(rowyRunAtom, globalScope);
const [compatibleRowyRunVersion] = useAtom(
compatibleRowyRunVersionAtom,
globalScope
);
const [tableSettings] = useAtom(tableSettingsAtom, tableScope);
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,
},
}).catch(console.log)
);
return () => setAuditChange(undefined);
}, [setAuditChange, rowyRun, compatibleRowyRunVersion, tableSettings]);
}

View File

@@ -0,0 +1,65 @@
import { useEffect } from "react";
import { useAtom, useSetAtom } from "jotai";
import { chunk, set } from "lodash-es";
import { doc, writeBatch, deleteField } from "firebase/firestore";
import { globalScope } from "@src/atoms/globalScope";
import { tableScope, _bulkWriteDbAtom } from "@src/atoms/tableScope";
import { BulkWriteFunction } from "@src/types/table";
import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase";
/**
* Sets the value of _bulkWriteDb atom
*/
export default function useBulkWriteDb() {
// Set _bulkWriteDb function
const [firebaseDb] = useAtom(firebaseDbAtom, globalScope);
const setBulkWriteDb = useSetAtom(_bulkWriteDbAtom, tableScope);
useEffect(() => {
setBulkWriteDb(
() =>
async (
operations: Parameters<BulkWriteFunction>[0],
onBatchCommit: Parameters<BulkWriteFunction>[1]
) => {
// Chunk operations into batches of 500 (Firestore limit is 500)
const operationsChunked = chunk(operations, 500);
// Loop through chunks of 500, then commit the batch sequentially
for (const [index, operationsChunk] of operationsChunked.entries()) {
// Create Firestore batch transaction
const batch = writeBatch(firebaseDb);
// Loop through operations and write to batch
for (const operation of operationsChunk) {
// New document
if (operation.type === "add") {
batch.set(doc(firebaseDb, operation.path), operation.data);
}
// Update existing document and merge values and delete fields
else if (operation.type === "update") {
const updateToDb = { ...operation.data };
if (Array.isArray(operation.deleteFields)) {
for (const field of operation.deleteFields) {
set(updateToDb as any, field, deleteField());
}
}
batch.set(doc(firebaseDb, operation.path), operation.data, {
merge: true,
});
}
// Delete existing documents
else if (operation.type === "delete") {
batch.delete(doc(firebaseDb, operation.path));
}
}
// Commit batch and wait for it to finish before continuing
// to prevent Firestore rate limits
await batch.commit().then(() => console.log("Batch committed"));
if (onBatchCommit) onBatchCommit(index + 1);
}
}
);
return () => setBulkWriteDb(undefined);
}, [firebaseDb, setBulkWriteDb]);
}

View File

@@ -8,6 +8,9 @@ import {
} from "@src/atoms/globalScope";
import { USERS } from "@src/config/dbPaths";
/**
* When rendered, provides atom values for top-level tables
*/
const UserManagementSourceFirebase = memo(
function UserManagementSourceFirebase() {
useFirestoreCollectionWithAtom(allUsersAtom, globalScope, USERS, {