mirror of
https://github.com/docmost/docmost.git
synced 2026-07-13 05:34:42 +10:00
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
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 handleCardClick = useCallback((rowId: string) => {
|
|
// Phase 5 wires this to the URL-driven row detail modal.
|
|
// Until then, noop — the click still registers and the focus ring
|
|
// appears, but nothing opens.
|
|
void rowId;
|
|
}, []);
|
|
|
|
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}
|
|
onCardClick={handleCardClick}
|
|
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}
|
|
onCardClick={handleCardClick}
|
|
persistViewConfig={persistViewConfig}
|
|
scrollportRef={scrollportRef}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|