Files
docmost/apps/client/src/ee/base/components/base-view.tsx
T
Philip Okugbe 89f13c3fbc feat(ee): bases (#2295)
* feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension.

* - default status
- type fix
- error helper

* fix: base trash list handling

* feat: base nodeview menu

* feat: translation

* fix number precision

* feat(base): add focused-cell atom and cell coordinate types

* feat(base): add cell focus-ring style

* feat(base): add pure next-cell navigation helper

* feat(base): keyboard navigation controller and grid wiring

* update offerings

* feat(base): cell focus ring, click-to-focus, and gridcell ARIA

* feat(base): row ARIA index and selected state

* feat(base): seed editor value on type-to-edit for free-text cells

* feat(base): make column headers keyboard-focusable as tab stops

* fix(base): remove focus outline on grid container

* fix(base): show cell focus ring only while the grid is focused

* feat(base): keyboard-navigate the row-number column for selection

* fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order

* fix(base): tab from long-text editor moves to next cell instead of leaving the table

* fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop

* fix(base): show cell focus ring only while the grid body itself is focused

* fix(base): render view-tab rename as an inline pill so the tab band height stays put

* fix(base): refer to the feature as 'base' rather than 'database'

* fix: change permissions object shape

* license file

* fix tsconfig

* fix base cache

* fix: preserve sidebar title/icon on partial page updates

* fix: skip duplicate row fetch when opening new kanban card

* fix refetch

* fix focus

* fix spacing

* fix(base): select grid cell on mousedown to avoid stale focus ring flash

The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell.

Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints.

* fix: activate New row button via keyboard (Enter/Space)

The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell.

Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys.

* fix(base): keep add-property popover within viewport on mobile

Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it.

Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active.

* fix(base): enable grid cell editing on touch devices

Cells could only enter edit mode via double-click or a physical keyboard,
so touch devices had no way to edit a cell. Treat a touch/pen tap as the
edit gesture, distinguishing a tap from a scroll by movement and branching
per pointer type so mouse double-click stays unchanged. Also reveal the
row expand button on hover-less devices so the row detail view stays
reachable.

* feat(editor): add base and kanban inserts to the toolbar

* feat(base): insert row below via Shift+Enter on the primary cell

* fix(base): place caret at end instead of selecting all when editing cells

* fix(base): prevent popover inputs from losing focus on mobile in row detail modal

* fix grid cells on mobile

* sync

* fix: read-only export

* feat(base): add prefixed nanoid id schemas and generators

* feat(base): enforce strict property/choice id validation

* feat(base): make property id varchar with per-base composite pk

* feat(base): pass property id as text to cell extractors

* feat(base): scope property lookups per base and generate property ids in repo

* feat(base): generate status template choice ids as nanoid

* feat(base): generate choice ids as nanoid on the client

* chore(base): seed choice ids with nanoid

* fix(base): mint kanban choice ids as nanoid

* sync

* sync

* sync
2026-06-21 03:07:14 +01:00

542 lines
18 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import { IconTable } 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 "@/ee/base/queries/base-query";
import { useBaseSocket } from "@/ee/base/hooks/use-base-socket";
import {
FilterGroup,
ViewSortConfig,
EditingCell,
FocusedCell,
IBaseProperty,
} from "@/ee/base/types/base.types";
import {
useBaseRowsQuery,
flattenRows,
useCreateRowMutation,
useUpdateRowMutation,
useReorderRowMutation,
} from "@/ee/base/queries/base-row-query";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import {
activeViewIdAtomFamily,
editingCellAtomFamily,
focusedCellAtomFamily,
} from "@/ee/base/atoms/base-atoms";
import { useBaseTable } from "@/ee/base/hooks/use-base-table";
import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useHydrateCurrentUser } from "@/ee/base/reference/reference-store";
import { useViewDraft } from "@/ee/base/hooks/use-view-draft";
import { BaseToolbar } from "@/ee/base/components/base-toolbar";
import { BaseViewDraftBanner } from "@/ee/base/components/base-view-draft-banner";
import { BaseEmbedTitle } from "@/ee/base/components/base-embed-title";
import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton";
import { ViewRenderer } from "@/ee/base/components/views/view-renderer";
import { RowDetailModal } from "@/ee/base/components/row-detail-modal/row-detail-modal";
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
import { BaseEditableProvider } from "@/ee/base/context/base-editable";
import { RowExpandProvider } from "@/ee/base/context/row-expand";
import { usePageQuery } from "@/features/page/queries/page-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { getAppUrl } from "@/lib/config.ts";
import { useNavigate } from "react-router-dom";
import classes from "@/ee/base/styles/grid.module.css";
import viewClasses from "@/ee/base/styles/base-view.module.css";
import kanbanClasses from "@/ee/base/styles/kanban.module.css";
type BaseViewProps = {
pageId: string;
embedded?: boolean;
/** False makes the view read-only. Standalone passes page.permissions.canEdit;
* embedded ANDs that with the host editor's editability. */
editable?: boolean;
titleSlot?: React.ReactNode;
};
export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseViewProps) {
const { t } = useTranslation();
// Subscribe so other clients' edits, schema changes, and async-job completions reconcile into cache.
useBaseSocket(pageId);
const { data: base, isLoading: baseLoading, error: baseError } =
useBaseQuery(pageId);
const navigate = useNavigate();
const { data: page } = usePageQuery({ pageId });
const handleExpand = useCallback(() => {
if (!page) return;
navigate(buildPageUrl(page.space?.slug, page.slugId, page.title));
}, [navigate, page]);
// Share URL for a specific view; always points at the standalone page where ?view= is honored.
const getViewShareUrl = useCallback(
(viewId: string) =>
page
? `${getAppUrl()}${buildPageUrl(page.space?.slug, page.slugId, page.title)}?view=${encodeURIComponent(viewId)}`
: null,
[page],
);
const [activeViewId, setActiveViewId] = useAtom(
activeViewIdAtomFamily(pageId),
) as unknown as [string | null, (val: string | null) => void];
const [, setEditingCell] = useAtom(
editingCellAtomFamily(pageId),
) as unknown as [EditingCell, (val: EditingCell) => void];
const [, setFocusedCell] = useAtom(
focusedCellAtomFamily(pageId),
) as unknown as [FocusedCell, (val: FocusedCell) => void];
const views = useMemo(
() =>
[...(base?.views ?? [])].sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
),
[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();
useHydrateCurrentUser(pageId);
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,
});
// Baseline merged with local draft. Used for table state and toolbar badge counts.
// The real activeView remains the auto-persist baseline so drafts can't leak into layout writes.
const effectiveView = useMemo(
() =>
activeView
? {
...activeView,
config: {
...activeView.config,
filter: effectiveFilter,
sorts: effectiveSorts,
},
}
: undefined,
[activeView, effectiveFilter, effectiveSorts],
);
const activeFilter = effectiveFilter;
const activeSorts = effectiveSorts;
const canSave = editable;
// Gate on base to avoid a "bland" list request before the active view's
// config resolves, which would double network traffic for sorted/filtered views.
const isKanban = activeView?.type === "kanban";
const {
data: rowsData,
isLoading: rowsLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts);
const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const updateViewMutation = useUpdateViewMutation();
useEffect(() => {
if (activeView && activeViewId !== activeView.id) {
setActiveViewId(activeView.id);
}
}, [activeView, activeViewId, setActiveViewId]);
// Deep link: apply ?view=<id> once after views load; skip if the id is
// unrecognised so we fall back to the default without fighting a later tab switch.
const appliedViewParamRef = useRef(false);
useEffect(() => {
if (appliedViewParamRef.current || views.length === 0) return;
const viewParam = new URLSearchParams(window.location.search).get("view");
if (viewParam && views.some((v) => v.id === viewParam)) {
setActiveViewId(viewParam);
}
appliedViewParamRef.current = true;
}, [views, setActiveViewId]);
const { clear: clearSelection } = useRowSelection(pageId);
useEffect(() => {
clearSelection();
}, [pageId, activeView?.id, clearSelection]);
const scrollportRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// With an active sort the server returns rows in sort order via keyset
// pagination; re-sorting by position on the client would break it as more
// pages load. Position sort only applies when no view sort is active.
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 rowsRef = useRef(rows);
rowsRef.current = rows;
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView);
const guardedPersistViewConfig = useCallback(() => {
if (!editable) return;
persistViewConfig();
}, [editable, persistViewConfig]);
// Mutation result objects change identity every render; only .mutate is
// stable. Rows are memoized on these callbacks' identities, so they must
// not churn with unrelated re-renders.
const updateRow = updateRowMutation.mutate;
const handleCellUpdate = useCallback(
(rowId: string, propertyId: string, value: unknown) => {
if (!editable) return;
updateRow({
rowId,
pageId,
cells: { [propertyId]: value },
});
},
[editable, pageId, updateRow],
);
const handleAddRow = useCallback(
(afterRowId?: string, focusPropertyId?: string) => {
if (!editable) return;
createRowMutation.mutate(
{ pageId, ...(afterRowId ? { afterRowId } : {}) },
{
onSuccess: (newRow) => {
let propertyId = focusPropertyId;
if (!propertyId) {
const firstEditable = table.getVisibleLeafColumns().find((col) => {
if (col.id === "__row_number") return false;
const prop = col.columnDef.meta?.property as
| IBaseProperty
| undefined;
return (
!!prop &&
prop.type !== "checkbox" &&
!isSystemPropertyType(prop.type)
);
});
propertyId = (
firstEditable?.columnDef.meta?.property as
| IBaseProperty
| undefined
)?.id;
}
if (propertyId) {
setEditingCell({ rowId: newRow.id, propertyId });
setFocusedCell({ rowId: newRow.id, propertyId });
}
},
},
);
},
[editable, pageId, createRowMutation, table, setEditingCell, setFocusedCell],
);
const handleViewChange = useCallback(
(viewId: string) => {
setActiveViewId(viewId);
},
[setActiveViewId],
);
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 }));
guardedPersistViewConfig();
},
[table, guardedPersistViewConfig],
);
const handleResizeEnd = useCallback(() => {
guardedPersistViewConfig();
}, [guardedPersistViewConfig]);
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;
// Preserves non-draft baseline fields (widths/order/visibility), overwrites only filter/sorts.
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 shows a toast and rolls back; keep the draft so the user can retry.
}
}, [
activeView,
base,
buildPromotedConfig,
resetDraft,
t,
updateViewMutation,
]);
const { openRowId, openRow, closeRow } = useRowDetailModal(pageId);
// openRow's identity tracks searchParams; rows subscribe to the expand
// context, so hand them a stable wrapper instead.
const openRowRef = useRef(openRow);
openRowRef.current = openRow;
const handleExpandRow = useCallback((rowId: string) => {
openRowRef.current(rowId);
}, []);
const handleRowNavigate = useCallback((rowId: string) => {
openRowRef.current(rowId, { replace: true });
}, []);
const reorderRow = reorderRowMutation.mutate;
const handleRowReorder = useCallback(
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
if (!editable) return;
const remainingRows = rowsRef.current.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);
}
reorderRow({ rowId, pageId, position: newPosition });
} catch {
// Position computation failed; skip silently.
}
},
[editable, pageId, reorderRow],
);
if (baseLoading || (!isKanban && rowsLoading)) {
return <BaseTableSkeleton />;
}
if (baseError) {
return (
<Stack align="center" gap="sm" p="xl">
<IconTable size={40} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{t("Failed to load base")}</Text>
</Stack>
);
}
if (!base) return null;
// Ghost rows are an "empty base" affordance, not a "filter matched nothing" state.
const isFiltered = (activeFilter?.children?.length ?? 0) > 0;
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}
canAddView={editable}
onPersistViewConfig={guardedPersistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
onExpand={embedded ? handleExpand : undefined}
getViewShareUrl={getViewShareUrl}
/>
);
const kanbanBand = (
<div className={kanbanClasses.bandWrap}>
{embedded ? null : titleSlot}
{banner}
{toolbar}
{embedded ? <BaseEmbedTitle pageId={pageId} /> : null}
</div>
);
const viewRenderer = (folded: React.ReactNode) => (
<ViewRenderer
base={base}
rows={rows}
effectiveView={effectiveView}
table={table}
pageId={pageId}
embedded={embedded}
editable={editable}
isFiltered={isFiltered}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
onColumnReorder={editable ? handleColumnReorder : undefined}
onResizeEnd={handleResizeEnd}
onRowReorder={editable ? handleRowReorder : undefined}
persistViewConfig={guardedPersistViewConfig}
scrollportRef={scrollportRef}
kanbanFilter={activeFilter}
aboveBand={folded}
/>
);
if (embedded) {
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{viewRenderer(null)}
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// Banner and toolbar go into aboveBand so they scroll with the host document;
// only the column-header row stays pinned (via --sticky-band-top).
return (
<BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}>
{viewRenderer(
<>
{banner}
{toolbar}
<BaseEmbedTitle pageId={pageId} />
</>,
)}
</RowExpandProvider>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
if (isKanban) {
return (
<BaseEditableProvider editable={editable}>
<div className={kanbanClasses.standalone}>
<RowExpandProvider value={handleExpandRow}>
{kanbanBand}
{viewRenderer(null)}
</RowExpandProvider>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}
// Standalone: title, banner, and toolbar go in aboveBand inside the scroll
// container so they scroll away; only the column-header row stays pinned.
return (
<BaseEditableProvider editable={editable}>
<div className={viewClasses.fullHeight}>
<div className={classes.tableScrollport} ref={scrollportRef}>
<RowExpandProvider value={handleExpandRow}>
{viewRenderer(
<>
{titleSlot}
{banner}
{toolbar}
</>,
)}
</RowExpandProvider>
</div>
</div>
<RowDetailModal
base={base}
rows={rows}
openRowId={openRowId}
onClose={closeRow}
onNavigate={handleRowNavigate}
/>
</BaseEditableProvider>
);
}