mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 17:27:06 +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 { GridContainer } from "@/features/base/components/grid/grid-container";
|
||||||
import { BaseToolbar } from "@/features/base/components/base-toolbar";
|
import { Table } from "@tanstack/react-table";
|
||||||
import { BaseViewDraftBanner } from "@/features/base/components/base-view-draft-banner";
|
import {
|
||||||
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
|
IBase,
|
||||||
import classes from "@/features/base/styles/grid.module.css";
|
IBaseRow,
|
||||||
|
IBaseView,
|
||||||
|
} from "@/features/base/types/base.types";
|
||||||
|
|
||||||
type BaseTableProps = {
|
type BaseTableProps = {
|
||||||
|
base: IBase;
|
||||||
|
rows: IBaseRow[];
|
||||||
|
effectiveView: IBaseView | undefined;
|
||||||
|
table: Table<IBaseRow>;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
embedded?: boolean;
|
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) {
|
export function BaseTable({
|
||||||
const { t } = useTranslation();
|
base,
|
||||||
// Subscribe to the base's realtime room so other clients' edits,
|
rows: _rows,
|
||||||
// schema changes, and async-job completions reconcile into our cache.
|
table,
|
||||||
useBaseSocket(pageId);
|
pageId,
|
||||||
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(pageId);
|
embedded,
|
||||||
|
hasNextPage,
|
||||||
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
|
isFetchingNextPage,
|
||||||
|
onFetchNextPage,
|
||||||
const views = base?.views ?? [];
|
onCellUpdate,
|
||||||
const activeView = useMemo(() => {
|
onAddRow,
|
||||||
if (!views.length) return undefined;
|
onColumnReorder,
|
||||||
return views.find((v) => v.id === activeViewId) ?? views[0];
|
onResizeEnd,
|
||||||
}, [views, activeViewId]);
|
onRowReorder,
|
||||||
|
scrollportRef,
|
||||||
const { data: currentUser } = useCurrentUser();
|
stickyBandPrelude,
|
||||||
const {
|
}: BaseTableProps) {
|
||||||
draft: _draft,
|
return (
|
||||||
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 = (
|
|
||||||
<GridContainer
|
<GridContainer
|
||||||
table={table}
|
table={table}
|
||||||
properties={base.properties}
|
properties={base.properties}
|
||||||
onCellUpdate={handleCellUpdate}
|
onCellUpdate={onCellUpdate}
|
||||||
onAddRow={handleAddRow}
|
onAddRow={onAddRow}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
onColumnReorder={handleColumnReorder}
|
onColumnReorder={onColumnReorder}
|
||||||
onResizeEnd={handleResizeEnd}
|
onResizeEnd={onResizeEnd}
|
||||||
onRowReorder={handleRowReorder}
|
onRowReorder={onRowReorder}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
onFetchNextPage={fetchNextPage}
|
onFetchNextPage={onFetchNextPage}
|
||||||
scrollElement={embedded ? window : scrollportRef.current}
|
scrollElement={embedded ? window : scrollportRef.current}
|
||||||
stickyBandPrelude={
|
stickyBandPrelude={stickyBandPrelude ?? null}
|
||||||
embedded ? (
|
|
||||||
<>
|
|
||||||
{banner}
|
|
||||||
{toolbar}
|
|
||||||
</>
|
|
||||||
) : 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 { NodeViewWrapper, NodeViewProps } from "@tiptap/react";
|
||||||
import { Box, Text } from "@mantine/core";
|
import { Box, Text } from "@mantine/core";
|
||||||
import { useEffect, useRef } from "react";
|
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 { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
|
||||||
import { useBaseQuery } from "@/features/base/queries/base-query";
|
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export function BaseEmbedView({ node }: NodeViewProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
content = <BaseTable pageId={pageId} embedded />;
|
content = <BaseView pageId={pageId} embedded />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Container, Title, Text, Stack } from "@mantine/core";
|
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";
|
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||||
|
|
||||||
export default function BasePage() {
|
export default function BasePage() {
|
||||||
@@ -26,7 +26,7 @@ export default function BasePage() {
|
|||||||
{base.icon ? `${base.icon} ` : ""}{base.name}
|
{base.icon ? `${base.icon} ` : ""}{base.name}
|
||||||
</Title>
|
</Title>
|
||||||
)}
|
)}
|
||||||
<BaseTable pageId={pageId} />
|
<BaseView pageId={pageId} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
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 MemoizedFullEditor = React.memo(FullEditor);
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageHeader = React.memo(PageHeader);
|
const MemoizedPageHeader = React.memo(PageHeader);
|
||||||
@@ -139,7 +139,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BaseTable pageId={page.id} />
|
<BaseView pageId={page.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user