mirror of
https://github.com/docmost/docmost.git
synced 2026-06-11 02:36:56 +08:00
refactor(bases): split BaseTable into BaseView shell + ViewRenderer
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user