mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
support sub-tables with nested routes & jotai scoped providers
This commit is contained in:
15
src/App.tsx
15
src/App.tsx
@@ -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 />} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function TableSettings() {
|
||||
openTableSettingsDialog({ mode: "update", data: tableSettings })
|
||||
}
|
||||
icon={<SettingsIcon />}
|
||||
disabled={!openTableSettingsDialog}
|
||||
disabled={!openTableSettingsDialog || tableSettings.id.includes("/")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
127
src/pages/Table/ProvidedSubTablePage.tsx
Normal file
127
src/pages/Table/ProvidedSubTablePage.tsx
Normal 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 can’t 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>
|
||||
);
|
||||
}
|
||||
70
src/pages/Table/ProvidedTablePage.tsx
Normal file
70
src/pages/Table/ProvidedTablePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 don’t 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 doesn’t 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;
|
||||
84
src/sources/TableSourceFirestore/TableSourceFirestore.tsx
Normal file
84
src/sources/TableSourceFirestore/TableSourceFirestore.tsx
Normal 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 doesn’t 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;
|
||||
48
src/sources/TableSourceFirestore/handleFirestoreError.tsx
Normal file
48
src/sources/TableSourceFirestore/handleFirestoreError.tsx
Normal 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);
|
||||
};
|
||||
2
src/sources/TableSourceFirestore/index.ts
Normal file
2
src/sources/TableSourceFirestore/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./TableSourceFirestore";
|
||||
export { default } from "./TableSourceFirestore";
|
||||
63
src/sources/TableSourceFirestore/useAuditChange.ts
Normal file
63
src/sources/TableSourceFirestore/useAuditChange.ts
Normal 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]);
|
||||
}
|
||||
65
src/sources/TableSourceFirestore/useBulkWriteDb.ts
Normal file
65
src/sources/TableSourceFirestore/useBulkWriteDb.ts
Normal 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]);
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user