refactor(bases): split BaseTable into BaseView shell + ViewRenderer

This commit is contained in:
Philipinho
2026-05-24 13:00:20 +01:00
parent 6b3babb3de
commit f75779951e
6 changed files with 503 additions and 380 deletions
@@ -1,391 +1,67 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/features/base/queries/base-query";
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
import {
FilterGroup,
ViewSortConfig,
} from "@/features/base/types/base.types";
import {
useBaseRowsQuery,
useBaseRowsCountQuery,
flattenRows,
} from "@/features/base/queries/base-row-query";
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
import {
useCreateViewMutation,
useUpdateViewMutation,
} from "@/features/base/queries/base-view-query";
import { activeViewIdAtomFamily } from "@/features/base/atoms/base-atoms";
import { useBaseTable } from "@/features/base/hooks/use-base-table";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useViewDraft } from "@/features/base/hooks/use-view-draft";
import { useSpaceQuery } from "@/features/space/queries/space-query";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
import { GridContainer } from "@/features/base/components/grid/grid-container";
import { BaseToolbar } from "@/features/base/components/base-toolbar";
import { BaseViewDraftBanner } from "@/features/base/components/base-view-draft-banner";
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
import classes from "@/features/base/styles/grid.module.css";
import { Table } from "@tanstack/react-table";
import {
IBase,
IBaseRow,
IBaseView,
} from "@/features/base/types/base.types";
type BaseTableProps = {
base: IBase;
rows: IBaseRow[];
effectiveView: IBaseView | undefined;
table: Table<IBaseRow>;
pageId: string;
embedded?: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
rowId: string,
targetRowId: string,
dropPosition: "above" | "below",
) => void;
persistViewConfig: () => void;
scrollportRef: React.RefObject<HTMLDivElement>;
stickyBandPrelude?: React.ReactNode;
};
export function BaseTable({ pageId, embedded }: BaseTableProps) {
const { t } = useTranslation();
// Subscribe to the base's realtime room so other clients' edits,
// schema changes, and async-job completions reconcile into our cache.
useBaseSocket(pageId);
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(pageId);
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
const views = base?.views ?? [];
const activeView = useMemo(() => {
if (!views.length) return undefined;
return views.find((v) => v.id === activeViewId) ?? views[0];
}, [views, activeViewId]);
const { data: currentUser } = useCurrentUser();
const {
draft: _draft,
effectiveFilter,
effectiveSorts,
isDirty,
setFilter: setDraftFilter,
setSorts: setDraftSorts,
reset: resetDraft,
buildPromotedConfig,
} = useViewDraft({
userId: currentUser?.user.id,
pageId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Render view: baseline merged with any local draft. Passed to
// `useBaseTable` (for table state seeding) and to the toolbar (for badge
// counts). The real `activeView` is still used as the auto-persist
// baseline so drafts can't leak into column-layout writes.
const effectiveView = useMemo(
() =>
activeView
? {
...activeView,
config: {
...activeView.config,
filter: effectiveFilter,
sorts: effectiveSorts,
},
}
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Effective values drive the row query and the client-side position
// sort guard below. The old `activeView.config` reads are no longer the
// source of truth once drafts are involved.
const activeFilter = effectiveFilter;
const activeSorts = effectiveSorts;
// `useSpaceQuery` is guarded by `enabled: !!spaceId` internally, so
// passing `""` when `base` hasn't loaded yet is safe. See
// use-history-restore.tsx for the same pattern.
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
// Bases are pages — gate save on the same Page subject the rest of
// the app uses; the dedicated Base subject was redundant.
const canSave = spaceAbility.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
// Hold the rows query until `base` has loaded. Otherwise the query
// fires once with `activeFilter` / `activeSorts` still undefined
// (a "bland" list request), then fires a second time as soon as the
// active view's config resolves — doubling network traffic on every
// base open for any view that has sort or filter.
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useBaseRowsQuery(base ? pageId : undefined, activeFilter, activeSorts);
// Fire the count request alongside the rows query. Not rendered yet —
// this mounts the query so its cache is warm for when the toolbar
// consumes it. Gate on `currentUser` too so `useViewDraft` has had a
// chance to hydrate the persisted draft from localStorage; otherwise
// the first post-refresh count would race ahead of the user's saved
// filter and fire with baseline-only (or nothing).
const canFetchCount = !!base && !!currentUser;
useBaseRowsCountQuery(
canFetchCount ? pageId : undefined,
activeFilter,
);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const createViewMutation = useCreateViewMutation();
const updateViewMutation = useUpdateViewMutation();
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
const { clear: clearSelection } = useRowSelection(pageId);
useEffect(() => {
clearSelection();
}, [pageId, activeView?.id, clearSelection]);
const scrollportRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// When a sort is active, the server returns rows in the requested
// sort order via keyset pagination. Re-sorting by `position` on the
// client would override that with fractional-index order — visibly
// breaking the sort as more pages load. Only apply the position
// sort when no view sort is active (where it keeps
// optimistically-created and ws-pushed rows in place without a
// refetch).
if (activeSorts && activeSorts.length > 0) {
return flat;
}
return flat.sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsData, activeSorts]);
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
updateRowMutation.mutate({
rowId,
pageId,
cells: { [propertyId]: value },
});
},
[pageId, updateRowMutation],
);
const handleAddRow = useCallback(() => {
createRowMutation.mutate({ pageId });
}, [pageId, createRowMutation]);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleAddView = useCallback(() => {
createViewMutation.mutate({
pageId,
name: t("New view"),
type: "table",
});
}, [pageId, createViewMutation, t]);
const handleColumnReorder = useCallback(
(columnId: string, finishIndex: number) => {
const order = table.getState().columnOrder;
const startIndex = order.indexOf(columnId);
if (startIndex === -1 || startIndex === finishIndex) return;
table.setColumnOrder(reorder({ list: order, startIndex, finishIndex }));
persistViewConfig();
},
[table, persistViewConfig],
);
const handleResizeEnd = useCallback(() => {
persistViewConfig();
}, [persistViewConfig]);
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[] | undefined) => {
setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined);
},
[setDraftSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => {
setDraftFilter(filter);
},
[setDraftFilter],
);
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
// `buildPromotedConfig` preserves all non-draft baseline fields
// (widths/order/visibility) and only overwrites filter/sorts when the
// draft has divergent values.
const config = buildPromotedConfig(activeView.config);
try {
await updateViewMutation.mutateAsync({
viewId: activeView.id,
pageId: base.id,
config,
});
resetDraft();
notifications.show({ message: t("View updated for everyone") });
} catch {
// `useUpdateViewMutation` already shows a red toast on error and
// rolls back the optimistic cache; keep the draft so the user can
// retry without re-typing.
}
}, [
activeView,
base,
buildPromotedConfig,
resetDraft,
t,
updateViewMutation,
]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
const remainingRows = rows.filter((r) => r.id !== rowId);
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (dropPosition === "above") {
lowerPos = targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
upperPos = remainingRows[targetIndex]?.position ?? null;
} else {
lowerPos = remainingRows[targetIndex]?.position ?? null;
upperPos = targetIndex < remainingRows.length - 1 ? remainingRows[targetIndex + 1]?.position : null;
}
try {
let newPosition: string;
if (lowerPos && upperPos && lowerPos === upperPos) {
newPosition = generateJitteredKeyBetween(lowerPos, null);
} else {
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
}
reorderRowMutation.mutate({
rowId,
pageId,
position: newPosition,
});
} catch {
// Position computation failed — skip silently
}
},
[rows, pageId, reorderRowMutation],
);
if (baseLoading || rowsLoading) {
return <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
const banner = (
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
);
const toolbar = (
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={handleAddView}
onPersistViewConfig={persistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
/>
);
const grid = (
export function BaseTable({
base,
rows: _rows,
table,
pageId,
embedded,
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
onCellUpdate,
onAddRow,
onColumnReorder,
onResizeEnd,
onRowReorder,
scrollportRef,
stickyBandPrelude,
}: BaseTableProps) {
return (
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onCellUpdate={onCellUpdate}
onAddRow={onAddRow}
pageId={pageId}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
onColumnReorder={onColumnReorder}
onResizeEnd={onResizeEnd}
onRowReorder={onRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onFetchNextPage={onFetchNextPage}
scrollElement={embedded ? window : scrollportRef.current}
stickyBandPrelude={
embedded ? (
<>
{banner}
{toolbar}
</>
) : null
}
stickyBandPrelude={stickyBandPrelude ?? null}
/>
);
if (embedded) {
// Inline: banner + toolbar live inside the StickyBand (passed via
// stickyBandPrelude). The page is the vertical scroll container.
return grid;
}
// Standalone: banner + toolbar sit above the .tableScrollport, which
// is the vertical scroll container. StickyBand inside contains only
// the column-header row.
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
{banner}
{toolbar}
<div className={classes.tableScrollport} ref={scrollportRef}>
{grid}
</div>
</div>
);
}
@@ -0,0 +1,404 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/features/base/queries/base-query";
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
import {
FilterGroup,
ViewSortConfig,
} from "@/features/base/types/base.types";
import {
useBaseRowsQuery,
useBaseRowsCountQuery,
flattenRows,
useCreateRowMutation,
useUpdateRowMutation,
useReorderRowMutation,
} from "@/features/base/queries/base-row-query";
import {
useCreateViewMutation,
useUpdateViewMutation,
} from "@/features/base/queries/base-view-query";
import { activeViewIdAtomFamily } from "@/features/base/atoms/base-atoms";
import { useBaseTable } from "@/features/base/hooks/use-base-table";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useViewDraft } from "@/features/base/hooks/use-view-draft";
import { useSpaceQuery } from "@/features/space/queries/space-query";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
import { BaseToolbar } from "@/features/base/components/base-toolbar";
import { BaseViewDraftBanner } from "@/features/base/components/base-view-draft-banner";
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
import { ViewRenderer } from "@/features/base/components/views/view-renderer";
import classes from "@/features/base/styles/grid.module.css";
type BaseViewProps = {
pageId: string;
embedded?: boolean;
};
export function BaseView({ pageId, embedded }: BaseViewProps) {
const { t } = useTranslation();
// Subscribe to the base's realtime room so other clients' edits,
// schema changes, and async-job completions reconcile into our cache.
useBaseSocket(pageId);
const { data: base, isLoading: baseLoading, error: baseError } =
useBaseQuery(pageId);
const [activeViewId, setActiveViewId] = useAtom(
activeViewIdAtomFamily(pageId),
) as unknown as [string | null, (val: string | null) => void];
const views = base?.views ?? [];
const activeView = useMemo(() => {
if (!views.length) return undefined;
return views.find((v) => v.id === activeViewId) ?? views[0];
}, [views, activeViewId]);
const { data: currentUser } = useCurrentUser();
const {
effectiveFilter,
effectiveSorts,
isDirty,
setFilter: setDraftFilter,
setSorts: setDraftSorts,
reset: resetDraft,
buildPromotedConfig,
} = useViewDraft({
userId: currentUser?.user.id,
pageId,
viewId: activeView?.id,
baselineFilter: activeView?.config?.filter,
baselineSorts: activeView?.config?.sorts,
});
// Render view: baseline merged with any local draft. Passed to
// `useBaseTable` (for table state seeding) and to the toolbar (for badge
// counts). The real `activeView` is still used as the auto-persist
// baseline so drafts can't leak into column-layout writes.
const effectiveView = useMemo(
() =>
activeView
? {
...activeView,
config: {
...activeView.config,
filter: effectiveFilter,
sorts: effectiveSorts,
},
}
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
// Effective values drive the row query and the client-side position
// sort guard below. The old `activeView.config` reads are no longer the
// source of truth once drafts are involved.
const activeFilter = effectiveFilter;
const activeSorts = effectiveSorts;
// `useSpaceQuery` is guarded by `enabled: !!spaceId` internally, so
// passing `""` when `base` hasn't loaded yet is safe. See
// use-history-restore.tsx for the same pattern.
const { data: space } = useSpaceQuery(base?.spaceId ?? "");
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
// Bases are pages — gate save on the same Page subject the rest of
// the app uses; the dedicated Base subject was redundant.
const canSave = spaceAbility.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
// Hold the rows query until `base` has loaded. Otherwise the query
// fires once with `activeFilter` / `activeSorts` still undefined
// (a "bland" list request), then fires a second time as soon as the
// active view's config resolves — doubling network traffic on every
// base open for any view that has sort or filter.
const {
data: rowsData,
isLoading: rowsLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useBaseRowsQuery(base ? pageId : undefined, activeFilter, activeSorts);
// Fire the count request alongside the rows query. Not rendered yet —
// this mounts the query so its cache is warm for when the toolbar
// consumes it. Gate on `currentUser` too so `useViewDraft` has had a
// chance to hydrate the persisted draft from localStorage; otherwise
// the first post-refresh count would race ahead of the user's saved
// filter and fire with baseline-only (or nothing).
const canFetchCount = !!base && !!currentUser;
useBaseRowsCountQuery(canFetchCount ? pageId : undefined, activeFilter);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const createViewMutation = useCreateViewMutation();
const updateViewMutation = useUpdateViewMutation();
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
const { clear: clearSelection } = useRowSelection(pageId);
useEffect(() => {
clearSelection();
}, [pageId, activeView?.id, clearSelection]);
const scrollportRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// When a sort is active, the server returns rows in the requested
// sort order via keyset pagination. Re-sorting by `position` on the
// client would override that with fractional-index order — visibly
// breaking the sort as more pages load. Only apply the position
// sort when no view sort is active (where it keeps
// optimistically-created and ws-pushed rows in place without a
// refetch).
if (activeSorts && activeSorts.length > 0) {
return flat;
}
return flat.sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsData, activeSorts]);
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
baselineConfig: activeView?.config,
});
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
updateRowMutation.mutate({
rowId,
pageId,
cells: { [propertyId]: value },
});
},
[pageId, updateRowMutation],
);
const handleAddRow = useCallback(() => {
createRowMutation.mutate({ pageId });
}, [pageId, createRowMutation]);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
const handleAddView = useCallback(() => {
createViewMutation.mutate({
pageId,
name: t("New view"),
type: "table",
});
}, [pageId, createViewMutation, t]);
const handleColumnReorder = useCallback(
(columnId: string, finishIndex: number) => {
const order = table.getState().columnOrder;
const startIndex = order.indexOf(columnId);
if (startIndex === -1 || startIndex === finishIndex) return;
table.setColumnOrder(reorder({ list: order, startIndex, finishIndex }));
persistViewConfig();
},
[table, persistViewConfig],
);
const handleResizeEnd = useCallback(() => {
persistViewConfig();
}, [persistViewConfig]);
const handleDraftSortsChange = useCallback(
(sorts: ViewSortConfig[] | undefined) => {
setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined);
},
[setDraftSorts],
);
const handleDraftFiltersChange = useCallback(
(filter: FilterGroup | undefined) => {
setDraftFilter(filter);
},
[setDraftFilter],
);
const handleSaveDraft = useCallback(async () => {
if (!activeView || !base) return;
// `buildPromotedConfig` preserves all non-draft baseline fields
// (widths/order/visibility) and only overwrites filter/sorts when the
// draft has divergent values.
const config = buildPromotedConfig(activeView.config);
try {
await updateViewMutation.mutateAsync({
viewId: activeView.id,
pageId: base.id,
config,
});
resetDraft();
notifications.show({ message: t("View updated for everyone") });
} catch {
// `useUpdateViewMutation` already shows a red toast on error and
// rolls back the optimistic cache; keep the draft so the user can
// retry without re-typing.
}
}, [
activeView,
base,
buildPromotedConfig,
resetDraft,
t,
updateViewMutation,
]);
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
const remainingRows = rows.filter((r) => r.id !== rowId);
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
if (targetIndex === -1) return;
let lowerPos: string | null = null;
let upperPos: string | null = null;
if (dropPosition === "above") {
lowerPos =
targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
upperPos = remainingRows[targetIndex]?.position ?? null;
} else {
lowerPos = remainingRows[targetIndex]?.position ?? null;
upperPos =
targetIndex < remainingRows.length - 1
? remainingRows[targetIndex + 1]?.position
: null;
}
try {
let newPosition: string;
if (lowerPos && upperPos && lowerPos === upperPos) {
newPosition = generateJitteredKeyBetween(lowerPos, null);
} else {
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
}
reorderRowMutation.mutate({ rowId, pageId, position: newPosition });
} catch {
// Position computation failed — skip silently.
}
},
[rows, pageId, reorderRowMutation],
);
if (baseLoading || rowsLoading) {
return <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
const banner = (
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
);
const toolbar = (
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={handleAddView}
onPersistViewConfig={persistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
/>
);
if (embedded) {
// Inline: banner + toolbar live inside the StickyBand (passed via
// stickyBandPrelude). The page is the vertical scroll container.
return (
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
persistViewConfig={persistViewConfig}
scrollportRef={scrollportRef}
stickyBandPrelude={
<>
{banner}
{toolbar}
</>
}
/>
);
}
// Standalone: banner + toolbar sit above the .tableScrollport, which
// is the vertical scroll container. StickyBand inside contains only
// the column-header row.
return (
<div
style={{ display: "flex", flexDirection: "column", height: "100%" }}
>
{banner}
{toolbar}
<div className={classes.tableScrollport} ref={scrollportRef}>
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
persistViewConfig={persistViewConfig}
scrollportRef={scrollportRef}
/>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { Table } from "@tanstack/react-table";
import {
IBase,
IBaseRow,
IBaseView,
} from "@/features/base/types/base.types";
import { BaseTable } from "@/features/base/components/base-table";
type ViewRendererProps = {
base: IBase;
rows: IBaseRow[];
effectiveView: IBaseView | undefined;
table: Table<IBaseRow>;
pageId: string;
embedded?: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onFetchNextPage: () => void;
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow: () => void;
onColumnReorder: (columnId: string, finishIndex: number) => void;
onResizeEnd: () => void;
onRowReorder: (
rowId: string,
targetRowId: string,
dropPosition: "above" | "below",
) => void;
persistViewConfig: () => void;
scrollportRef: React.RefObject<HTMLDivElement>;
stickyBandPrelude?: React.ReactNode;
};
export function ViewRenderer(props: ViewRendererProps) {
const viewType = props.effectiveView?.type ?? "table";
if (viewType === "table") {
return <BaseTable {...props} />;
}
// Kanban added in a later task; until then, fall back to the table so
// selecting a kanban view never produces a blank page.
return <BaseTable {...props} />;
}
@@ -1,7 +1,7 @@
import { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
import { Box, Text } from "@mantine/core";
import { useEffect, useRef } from "react";
import { BaseTable } from "@/features/base/components/base-table";
import { BaseView } from "@/features/base/components/base-view";
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
import { useBaseQuery } from "@/features/base/queries/base-query";
@@ -99,7 +99,7 @@ export function BaseEmbedView({ node }: NodeViewProps) {
</Box>
);
} else {
content = <BaseTable pageId={pageId} embedded />;
content = <BaseView pageId={pageId} embedded />;
}
return (
+2 -2
View File
@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom";
import { Container, Title, Text, Stack } from "@mantine/core";
import { BaseTable } from "@/features/base/components/base-table";
import { BaseView } from "@/features/base/components/base-view";
import { useBaseQuery } from "@/features/base/queries/base-query";
export default function BasePage() {
@@ -26,7 +26,7 @@ export default function BasePage() {
{base.icon ? `${base.icon} ` : ""}{base.name}
</Title>
)}
<BaseTable pageId={pageId} />
<BaseView pageId={pageId} />
</Container>
);
}
+2 -2
View File
@@ -14,7 +14,7 @@ import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { Link } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { BaseTable } from "@/features/base/components/base-table";
import { BaseView } from "@/features/base/components/base-view";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageHeader = React.memo(PageHeader);
@@ -139,7 +139,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
flexDirection: "column",
}}
>
<BaseTable pageId={page.id} />
<BaseView pageId={page.id} />
</div>
</div>
</div>