mirror of
https://github.com/docmost/docmost.git
synced 2026-07-21 08:52:48 +10:00
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
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAtomValue, getDefaultStore } from "jotai";
|
||||
import { useQueryClient, InfiniteData } from "@tanstack/react-query";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { selectedRowIdsAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||
import { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import { invalidateBaseRows } from "@/ee/base/queries/base-row-query";
|
||||
|
||||
type BaseRowCreated = {
|
||||
operation: "base:row:created";
|
||||
pageId: string;
|
||||
row: IBaseRow;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowUpdated = {
|
||||
operation: "base:row:updated";
|
||||
pageId: string;
|
||||
rowId: string;
|
||||
updatedCells: Record<string, unknown>;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowDeleted = {
|
||||
operation: "base:row:deleted";
|
||||
pageId: string;
|
||||
rowId: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowsDeleted = {
|
||||
operation: "base:rows:deleted";
|
||||
pageId: string;
|
||||
rowIds: string[];
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowReordered = {
|
||||
operation: "base:row:reordered";
|
||||
pageId: string;
|
||||
rowId: string;
|
||||
position: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BasePropertyEvent = {
|
||||
operation:
|
||||
| "base:property:created"
|
||||
| "base:property:updated"
|
||||
| "base:property:deleted"
|
||||
| "base:property:reordered";
|
||||
pageId: string;
|
||||
property?: IBaseProperty;
|
||||
propertyId?: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseViewEvent = {
|
||||
operation:
|
||||
| "base:view:created"
|
||||
| "base:view:updated"
|
||||
| "base:view:deleted";
|
||||
pageId: string;
|
||||
view?: IBaseView;
|
||||
viewId?: string;
|
||||
};
|
||||
|
||||
type BaseRowsUpdated = {
|
||||
operation: "base:rows:updated";
|
||||
pageId: string;
|
||||
rowIds: string[];
|
||||
propertyIds: string[];
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseFormulaRecomputeStarted = {
|
||||
operation: "base:formula:recompute:started";
|
||||
pageId: string;
|
||||
propertyIds: string[];
|
||||
jobId: string;
|
||||
};
|
||||
|
||||
type BaseFormulaRecomputeCompleted = {
|
||||
operation: "base:formula:recompute:completed";
|
||||
pageId: string;
|
||||
propertyIds: string[];
|
||||
jobId: string;
|
||||
processed: number;
|
||||
errored: number;
|
||||
};
|
||||
|
||||
type BaseSchemaBumped = {
|
||||
operation: "base:schema:bumped";
|
||||
pageId: string;
|
||||
schemaVersion: number;
|
||||
};
|
||||
|
||||
type BaseSubscribed = {
|
||||
operation: "base:subscribed";
|
||||
pageId: string;
|
||||
schemaVersion: number;
|
||||
};
|
||||
|
||||
type BaseInboundEvent =
|
||||
| BaseRowCreated
|
||||
| BaseRowUpdated
|
||||
| BaseRowDeleted
|
||||
| BaseRowsDeleted
|
||||
| BaseRowReordered
|
||||
| BaseRowsUpdated
|
||||
| BaseFormulaRecomputeStarted
|
||||
| BaseFormulaRecomputeCompleted
|
||||
| BaseSchemaBumped
|
||||
| BaseSubscribed
|
||||
| BasePropertyEvent
|
||||
| BaseViewEvent
|
||||
| { operation: string; pageId: string };
|
||||
|
||||
// Module-level set of requestIds we've just sent. When the socket echoes back
|
||||
// a mutation with a matching requestId we drop it, as the local mutation
|
||||
// already updated the cache. Bounded to prevent unbounded growth on long tabs.
|
||||
const outboundRequestIds = new Set<string>();
|
||||
const OUTBOUND_MAX = 256;
|
||||
|
||||
export function markRequestIdOutbound(requestId: string): void {
|
||||
outboundRequestIds.add(requestId);
|
||||
if (outboundRequestIds.size > OUTBOUND_MAX) {
|
||||
const oldest = outboundRequestIds.values().next().value;
|
||||
if (oldest) outboundRequestIds.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// Realtime bridge for a single base. Joins the base-{pageId} room on mount,
|
||||
// leaves on unmount, and reconciles React Query caches on inbound events.
|
||||
export function useBaseSocket(pageId: string | undefined): void {
|
||||
const socket = useAtomValue(socketAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !pageId) return;
|
||||
|
||||
socket.emit("message", { operation: "base:subscribe", pageId });
|
||||
|
||||
const handler = (raw: unknown) => {
|
||||
if (!raw || typeof raw !== "object") return;
|
||||
const event = raw as BaseInboundEvent;
|
||||
if (event.pageId !== pageId) return;
|
||||
|
||||
const requestId = (event as any).requestId as string | undefined;
|
||||
if (requestId && outboundRequestIds.has(requestId)) {
|
||||
outboundRequestIds.delete(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.operation) {
|
||||
case "base:row:created": {
|
||||
const e = event as BaseRowCreated;
|
||||
const baseForCreate = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||
const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
|
||||
if (hasKanbanForCreate) {
|
||||
invalidateBaseRows(pageId);
|
||||
} else {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const lastPageIndex = old.pages.length - 1;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) =>
|
||||
index === lastPageIndex
|
||||
? { ...page, items: [...page.items, e.row] }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:row:updated": {
|
||||
const e = event as BaseRowUpdated;
|
||||
const baseForUpdate = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||
const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban");
|
||||
if (hasKanbanForUpdate) {
|
||||
invalidateBaseRows(pageId);
|
||||
} else {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === e.rowId
|
||||
? {
|
||||
...row,
|
||||
cells: { ...row.cells, ...e.updatedCells },
|
||||
}
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:row:deleted": {
|
||||
const e = event as BaseRowDeleted;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => row.id !== e.rowId),
|
||||
})),
|
||||
},
|
||||
);
|
||||
const store = getDefaultStore();
|
||||
const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
|
||||
const current = store.get(selectedIdsAtom);
|
||||
if (current.has(e.rowId)) {
|
||||
const next = new Set(current);
|
||||
next.delete(e.rowId);
|
||||
store.set(selectedIdsAtom, next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:rows:deleted": {
|
||||
const e = event as BaseRowsDeleted;
|
||||
const removeSet = new Set(e.rowIds);
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => !removeSet.has(row.id)),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
const store = getDefaultStore();
|
||||
const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
|
||||
const current = store.get(selectedIdsAtom);
|
||||
if (current.size > 0) {
|
||||
let changed = false;
|
||||
const next = new Set(current);
|
||||
for (const id of e.rowIds) {
|
||||
if (next.delete(id)) changed = true;
|
||||
}
|
||||
if (changed) store.set(selectedIdsAtom, next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:row:reordered": {
|
||||
const e = event as BaseRowReordered;
|
||||
const baseForReorder = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||
const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban");
|
||||
if (hasKanbanForReorder) {
|
||||
invalidateBaseRows(pageId);
|
||||
} else {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", pageId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === e.rowId
|
||||
? { ...row, position: e.position }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:rows:updated": {
|
||||
const e = event as BaseRowsUpdated;
|
||||
// Only refetch if the batch touches rows currently in cache; formula
|
||||
// backfills emit one event per 500 rows so this avoids redundant fetches.
|
||||
const updatedIds = new Set(e.rowIds);
|
||||
const caches = queryClient.getQueriesData<
|
||||
InfiniteData<IPagination<IBaseRow>>
|
||||
>({ queryKey: ["base-rows", pageId] });
|
||||
let touchesCache = false;
|
||||
outer: for (const [, data] of caches) {
|
||||
if (!data) continue;
|
||||
for (const page of data.pages) {
|
||||
for (const row of page.items) {
|
||||
if (updatedIds.has(row.id)) {
|
||||
touchesCache = true;
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (touchesCache) {
|
||||
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:schema:bumped": {
|
||||
// Worker committed a type conversion or cell GC; re-fetch under the new schema.
|
||||
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
|
||||
break;
|
||||
}
|
||||
case "base:subscribed": {
|
||||
const e = event as BaseSubscribed;
|
||||
const cached = queryClient.getQueryData<IBase>(["bases", pageId]);
|
||||
if (cached && cached.baseSchemaVersion !== e.schemaVersion) {
|
||||
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:formula:recompute:started": {
|
||||
const e = event as BaseFormulaRecomputeStarted;
|
||||
const store = getDefaultStore();
|
||||
store.set(formulaRecomputeAtom, {
|
||||
...store.get(formulaRecomputeAtom),
|
||||
[e.jobId]: e.propertyIds,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "base:formula:recompute:completed": {
|
||||
const e = event as BaseFormulaRecomputeCompleted;
|
||||
const store = getDefaultStore();
|
||||
const current = store.get(formulaRecomputeAtom);
|
||||
if (e.jobId in current) {
|
||||
const next = { ...current };
|
||||
delete next[e.jobId];
|
||||
store.set(formulaRecomputeAtom, next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:property:created":
|
||||
case "base:property:updated":
|
||||
case "base:property:deleted":
|
||||
case "base:property:reordered":
|
||||
case "base:view:created":
|
||||
case "base:view:updated":
|
||||
case "base:view:deleted": {
|
||||
// Schema/metadata events only affect properties/views, not cell data.
|
||||
queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("message", handler);
|
||||
|
||||
return () => {
|
||||
socket.off("message", handler);
|
||||
socket.emit("message", { operation: "base:unsubscribe", pageId });
|
||||
};
|
||||
}, [socket, pageId, queryClient]);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useMemo, useCallback, useRef, useState, useEffect } from "react";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
createColumnHelper,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
ColumnSizingState,
|
||||
VisibilityState,
|
||||
ColumnOrderState,
|
||||
ColumnPinningState,
|
||||
Table,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
ViewConfig,
|
||||
ViewConfigPatch,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
|
||||
import { systemAccessorFor } from "@/ee/base/property-types/property-type.registry";
|
||||
|
||||
const DEFAULT_COLUMN_WIDTH = 180;
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
const MAX_COLUMN_WIDTH = 600;
|
||||
const ROW_NUMBER_COLUMN_WIDTH = 64;
|
||||
|
||||
const columnHelper = createColumnHelper<IBaseRow>();
|
||||
|
||||
function buildColumns(properties: IBaseProperty[]): ColumnDef<IBaseRow, unknown>[] {
|
||||
const rowNumberColumn = columnHelper.display({
|
||||
id: "__row_number",
|
||||
header: "#",
|
||||
size: ROW_NUMBER_COLUMN_WIDTH,
|
||||
minSize: ROW_NUMBER_COLUMN_WIDTH,
|
||||
maxSize: ROW_NUMBER_COLUMN_WIDTH,
|
||||
enableResizing: false,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
});
|
||||
|
||||
const propertyColumns = properties.map((property) => {
|
||||
const sysAccessor = systemAccessorFor(property.type);
|
||||
if (sysAccessor) {
|
||||
return columnHelper.accessor(sysAccessor, {
|
||||
id: property.id,
|
||||
header: property.name,
|
||||
size: DEFAULT_COLUMN_WIDTH,
|
||||
minSize: MIN_COLUMN_WIDTH,
|
||||
maxSize: MAX_COLUMN_WIDTH,
|
||||
enableResizing: true,
|
||||
enableSorting: false,
|
||||
enableHiding: !property.isPrimary,
|
||||
meta: { property },
|
||||
});
|
||||
}
|
||||
|
||||
return columnHelper.accessor((row) => row.cells[property.id], {
|
||||
id: property.id,
|
||||
header: property.name,
|
||||
size: DEFAULT_COLUMN_WIDTH,
|
||||
minSize: MIN_COLUMN_WIDTH,
|
||||
maxSize: MAX_COLUMN_WIDTH,
|
||||
enableResizing: true,
|
||||
enableSorting: true,
|
||||
enableHiding: !property.isPrimary,
|
||||
meta: { property },
|
||||
});
|
||||
});
|
||||
|
||||
return [rowNumberColumn, ...propertyColumns];
|
||||
}
|
||||
|
||||
function buildSortingState(config: ViewConfig | undefined): SortingState {
|
||||
if (!config?.sorts?.length) return [];
|
||||
return config.sorts.map((sort) => ({
|
||||
id: sort.propertyId,
|
||||
desc: sort.direction === "desc",
|
||||
}));
|
||||
}
|
||||
|
||||
function buildColumnSizing(
|
||||
config: ViewConfig | undefined,
|
||||
): ColumnSizingState {
|
||||
const sizing: ColumnSizingState = {
|
||||
__row_number: ROW_NUMBER_COLUMN_WIDTH,
|
||||
};
|
||||
if (config?.propertyWidths) {
|
||||
Object.entries(config.propertyWidths).forEach(([id, width]) => {
|
||||
sizing[id] = width;
|
||||
});
|
||||
}
|
||||
return sizing;
|
||||
}
|
||||
|
||||
function buildColumnVisibility(
|
||||
config: ViewConfig | undefined,
|
||||
properties: IBaseProperty[],
|
||||
): VisibilityState {
|
||||
const visibility: VisibilityState = { __row_number: true };
|
||||
|
||||
if (config?.hiddenPropertyIds) {
|
||||
const hiddenSet = new Set(config.hiddenPropertyIds);
|
||||
properties.forEach((p) => {
|
||||
visibility[p.id] = !hiddenSet.has(p.id);
|
||||
});
|
||||
return visibility;
|
||||
}
|
||||
|
||||
if (config?.visiblePropertyIds?.length) {
|
||||
const visibleSet = new Set(config.visiblePropertyIds);
|
||||
properties.forEach((p) => {
|
||||
visibility[p.id] = visibleSet.has(p.id);
|
||||
});
|
||||
return visibility;
|
||||
}
|
||||
|
||||
properties.forEach((p) => {
|
||||
visibility[p.id] = true;
|
||||
});
|
||||
return visibility;
|
||||
}
|
||||
|
||||
function buildColumnOrder(
|
||||
config: ViewConfig | undefined,
|
||||
properties: IBaseProperty[],
|
||||
): ColumnOrderState {
|
||||
if (config?.propertyOrder?.length) {
|
||||
const orderSet = new Set(config.propertyOrder);
|
||||
const missing = properties
|
||||
.filter((p) => !orderSet.has(p.id))
|
||||
.sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0))
|
||||
.map((p) => p.id);
|
||||
return ["__row_number", ...config.propertyOrder, ...missing];
|
||||
}
|
||||
const sorted = [...properties].sort((a, b) => {
|
||||
if (a.isPrimary) return -1;
|
||||
if (b.isPrimary) return 1;
|
||||
return a.position < b.position ? -1 : a.position > b.position ? 1 : 0;
|
||||
});
|
||||
return ["__row_number", ...sorted.map((p) => p.id)];
|
||||
}
|
||||
|
||||
function buildColumnPinning(
|
||||
properties: IBaseProperty[],
|
||||
pinPrimary: boolean,
|
||||
): ColumnPinningState {
|
||||
const primary = pinPrimary ? properties.find((p) => p.isPrimary) : undefined;
|
||||
return {
|
||||
left: primary ? ["__row_number", primary.id] : ["__row_number"],
|
||||
right: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLayoutConfigPatch(table: Table<IBaseRow>): ViewConfigPatch {
|
||||
const state = table.getState();
|
||||
|
||||
const propertyWidths: Record<string, number> = {};
|
||||
Object.entries(state.columnSizing).forEach(([id, width]) => {
|
||||
if (id !== "__row_number") {
|
||||
// Resize state can hold the raw drag value below minSize; rendering
|
||||
// clamps via getSize(), so persist the clamped value too.
|
||||
propertyWidths[id] = Math.min(
|
||||
MAX_COLUMN_WIDTH,
|
||||
Math.max(MIN_COLUMN_WIDTH, width),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const propertyOrder = state.columnOrder.filter((id) => id !== "__row_number");
|
||||
|
||||
const hiddenPropertyIds = Object.entries(state.columnVisibility)
|
||||
.filter(([id, visible]) => id !== "__row_number" && !visible)
|
||||
.map(([id]) => id);
|
||||
|
||||
return {
|
||||
propertyWidths,
|
||||
propertyOrder,
|
||||
hiddenPropertyIds,
|
||||
visiblePropertyIds: null,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBaseTableResult = {
|
||||
table: Table<IBaseRow>;
|
||||
persistViewConfig: () => void;
|
||||
};
|
||||
|
||||
export function useBaseTable(
|
||||
base: IBase | undefined,
|
||||
rows: IBaseRow[],
|
||||
activeView: IBaseView | undefined,
|
||||
): UseBaseTableResult {
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// While a local edit is pending the reconcile effect preserves local state
|
||||
// to avoid stomping in-flight toggles. When idle it adopts server state so
|
||||
// remote updates from other clients (e.g. hiding a column) show up here.
|
||||
const [hasPendingEdit, setHasPendingEdit] = useState(false);
|
||||
|
||||
const properties = useMemo(() => base?.properties ?? [], [base?.properties]);
|
||||
const viewConfig = activeView?.config;
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildColumns(properties),
|
||||
[properties],
|
||||
);
|
||||
|
||||
const initialSorting = useMemo(
|
||||
() => buildSortingState(viewConfig),
|
||||
[viewConfig],
|
||||
);
|
||||
|
||||
const derivedColumnSizing = useMemo(
|
||||
() => buildColumnSizing(viewConfig),
|
||||
[viewConfig],
|
||||
);
|
||||
|
||||
const derivedColumnOrder = useMemo(
|
||||
() => buildColumnOrder(viewConfig, properties),
|
||||
[viewConfig, properties],
|
||||
);
|
||||
|
||||
const derivedColumnVisibility = useMemo(
|
||||
() => buildColumnVisibility(viewConfig, properties),
|
||||
[viewConfig, properties],
|
||||
);
|
||||
|
||||
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>(derivedColumnOrder);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(derivedColumnVisibility);
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(derivedColumnSizing);
|
||||
|
||||
// Re-seed from the server only on view switch. Within the same view local
|
||||
// state is the source of truth. Without this guard, any ws-driven
|
||||
// invalidateQueries would land a new derivedColumnVisibility reference and
|
||||
// overwrite a pending toggle before persistViewConfig flushes it.
|
||||
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
|
||||
useEffect(() => {
|
||||
const currentViewId = activeView?.id;
|
||||
|
||||
if (currentViewId !== lastSyncedViewIdRef.current) {
|
||||
lastSyncedViewIdRef.current = currentViewId;
|
||||
setColumnOrder(derivedColumnOrder);
|
||||
setColumnVisibility(derivedColumnVisibility);
|
||||
setColumnSizing(derivedColumnSizing);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same view: if a local edit is pending, reconcile only the id set so
|
||||
// new/deleted columns appear without stomping the user's toggle.
|
||||
// If no edit is pending, adopt server state so remote updates show up.
|
||||
const validIds = new Set<string>(["__row_number"]);
|
||||
for (const p of properties) validIds.add(p.id);
|
||||
|
||||
if (hasPendingEdit) {
|
||||
setColumnOrder((prev) => {
|
||||
const prevSet = new Set(prev);
|
||||
const kept = prev.filter((id) => validIds.has(id));
|
||||
const appended = derivedColumnOrder.filter(
|
||||
(id) => !prevSet.has(id) && validIds.has(id),
|
||||
);
|
||||
if (appended.length === 0 && kept.length === prev.length) return prev;
|
||||
return [...kept, ...appended];
|
||||
});
|
||||
|
||||
setColumnVisibility((prev) => {
|
||||
let changed = false;
|
||||
const next: VisibilityState = {};
|
||||
for (const [id, visible] of Object.entries(prev)) {
|
||||
if (validIds.has(id)) {
|
||||
next[id] = visible;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const id of derivedColumnOrder) {
|
||||
if (!(id in next)) {
|
||||
next[id] = derivedColumnVisibility[id] ?? true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
setColumnSizing((prev) => {
|
||||
let changed = false;
|
||||
const next: ColumnSizingState = {};
|
||||
for (const [id, width] of Object.entries(prev)) {
|
||||
if (validIds.has(id)) {
|
||||
next[id] = width;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
} else {
|
||||
setColumnOrder(derivedColumnOrder);
|
||||
setColumnVisibility(derivedColumnVisibility);
|
||||
setColumnSizing(derivedColumnSizing);
|
||||
}
|
||||
}, [
|
||||
activeView?.id,
|
||||
derivedColumnOrder,
|
||||
derivedColumnVisibility,
|
||||
derivedColumnSizing,
|
||||
properties,
|
||||
hasPendingEdit,
|
||||
]);
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 48em)", false, {
|
||||
getInitialValueInEffect: false,
|
||||
});
|
||||
const columnPinning = useMemo(
|
||||
() => buildColumnPinning(properties, !isMobile),
|
||||
[properties, isMobile],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: {
|
||||
columnPinning,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
},
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
initialState: {
|
||||
sorting: initialSorting,
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
columnResizeMode: "onChange",
|
||||
enableColumnResizing: true,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
getRowId: (row) => row.id,
|
||||
});
|
||||
|
||||
const persistViewConfig = useCallback(() => {
|
||||
if (!activeView || !base) return;
|
||||
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
}
|
||||
|
||||
setHasPendingEdit(true);
|
||||
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
persistTimerRef.current = null;
|
||||
const config = buildLayoutConfigPatch(table);
|
||||
updateViewMutation.mutate(
|
||||
{ viewId: activeView.id, pageId: base.id, config },
|
||||
{
|
||||
onSettled: () => {
|
||||
// Only clear if no new debounce was scheduled while in flight.
|
||||
if (persistTimerRef.current === null) {
|
||||
setHasPendingEdit(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, 300);
|
||||
}, [activeView, base, table, updateViewMutation]);
|
||||
|
||||
return { table, persistViewConfig };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { IBaseRow } from "@/ee/base/types/base.types";
|
||||
|
||||
export function useColumnResize(
|
||||
table: Table<IBaseRow>,
|
||||
onResizeEnd: () => void,
|
||||
) {
|
||||
const wasResizingRef = useRef(false);
|
||||
|
||||
const checkResizeEnd = useCallback(() => {
|
||||
const isResizing = table.getState().columnSizingInfo.isResizingColumn;
|
||||
if (wasResizingRef.current && !isResizing) {
|
||||
onResizeEnd();
|
||||
}
|
||||
wasResizingRef.current = !!isResizing;
|
||||
}, [table, onResizeEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
checkResizeEnd();
|
||||
});
|
||||
|
||||
return {
|
||||
isResizing: !!table.getState().columnSizingInfo.isResizingColumn,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { useDeleteRowsMutation } from "@/ee/base/queries/base-row-query";
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
export function useDeleteSelectedRows(pageId: string) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedIds, clear } = useRowSelection(pageId);
|
||||
const mutation = useDeleteRowsMutation();
|
||||
|
||||
const runDelete = useCallback(
|
||||
async (ids: string[]) => {
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
||||
chunks.push(ids.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
await mutation.mutateAsync({ pageId, rowIds: chunk });
|
||||
}
|
||||
notifications.show({
|
||||
message: t("{{count}} rows deleted", { count: ids.length }),
|
||||
});
|
||||
clear();
|
||||
} catch {
|
||||
// mutation onError already shows notification
|
||||
}
|
||||
},
|
||||
[pageId, mutation, clear, t],
|
||||
);
|
||||
|
||||
const deleteSelected = useCallback(() => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete {{count}} rows?", { count: ids.length }),
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("This action cannot be undone.")}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => void runDelete(ids),
|
||||
});
|
||||
}, [selectedIds, runDelete, t]);
|
||||
|
||||
return { deleteSelected, isPending: mutation.isPending };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useStore, type PrimitiveAtom } from "jotai";
|
||||
import { pendingTypeInsertAtom, type PendingTypeInsert } from "@/ee/base/atoms/base-atoms";
|
||||
|
||||
export type UseEditableTextCellParams = {
|
||||
value: unknown;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
/** value -> the draft string shown in the input when editing begins */
|
||||
toDraft: (value: unknown) => string;
|
||||
/** draft string -> the value passed to onCommit */
|
||||
parse: (draft: string) => unknown;
|
||||
rowId?: string;
|
||||
propertyId?: string;
|
||||
};
|
||||
|
||||
export type EditableTextCell = {
|
||||
draft: string;
|
||||
setDraft: (draft: string) => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handleBlur: () => void;
|
||||
};
|
||||
|
||||
export function useEditableTextCell({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
toDraft,
|
||||
parse,
|
||||
rowId,
|
||||
propertyId,
|
||||
}: UseEditableTextCellParams): EditableTextCell {
|
||||
const [draft, setDraft] = useState(() => toDraft(value));
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
const wasEditingRef = useRef(false);
|
||||
const toDraftRef = useRef(toDraft);
|
||||
toDraftRef.current = toDraft;
|
||||
const store = useStore();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isEditing && !wasEditingRef.current) {
|
||||
committedRef.current = false;
|
||||
const pending = store.get(pendingTypeInsertAtom);
|
||||
const seeded =
|
||||
pending != null &&
|
||||
pending.rowId === rowId &&
|
||||
pending.propertyId === propertyId;
|
||||
const nextDraft = seeded ? pending.char : toDraftRef.current(value);
|
||||
if (seeded) {
|
||||
store.set(pendingTypeInsertAtom as PrimitiveAtom<PendingTypeInsert>, null);
|
||||
}
|
||||
setDraft(nextDraft);
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.value = nextDraft;
|
||||
el.focus({ preventScroll: true });
|
||||
try {
|
||||
el.setSelectionRange(nextDraft.length, nextDraft.length);
|
||||
} catch {
|
||||
// email/number inputs reject setSelectionRange
|
||||
}
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
}
|
||||
}
|
||||
wasEditingRef.current = isEditing;
|
||||
}, [isEditing, value, rowId, propertyId, store]);
|
||||
|
||||
const commitOnce = useCallback(
|
||||
(val: unknown) => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(val);
|
||||
},
|
||||
[onCommit],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitOnce(parse(draft));
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
committedRef.current = true;
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[draft, parse, commitOnce, onCancel],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
commitOnce(parse(draft));
|
||||
}, [draft, parse, commitOnce]);
|
||||
|
||||
return { draft, setDraft, inputRef, handleKeyDown, handleBlur };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useEscapeClose(opened: boolean, onClose: () => void) {
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [opened, onClose]);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
parseRaw,
|
||||
resolve,
|
||||
typecheck,
|
||||
BaseFormulaGraph,
|
||||
type FormulaResultType,
|
||||
type FormulaFn,
|
||||
} from "@docmost/base-formula/client";
|
||||
import type { IBaseProperty } from "@/ee/base/types/base.types";
|
||||
|
||||
type ParseState =
|
||||
| { state: "idle" }
|
||||
| {
|
||||
state: "ok";
|
||||
resultType: FormulaResultType;
|
||||
ast: unknown;
|
||||
dependencies: string[];
|
||||
}
|
||||
| {
|
||||
state: "error";
|
||||
code: string;
|
||||
message: string;
|
||||
span?: { start: number; end: number };
|
||||
};
|
||||
|
||||
export function useFormulaParser(
|
||||
source: string,
|
||||
properties: IBaseProperty[],
|
||||
editingPropertyId: string | null,
|
||||
registryForTypecheck: ReadonlyMap<string, FormulaFn>,
|
||||
): ParseState {
|
||||
const [state, setState] = useState<ParseState>({ state: "idle" });
|
||||
|
||||
const deps = useMemo(
|
||||
() => ({ source, properties, editingPropertyId, registryForTypecheck }),
|
||||
[source, properties, editingPropertyId, registryForTypecheck],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
if (!source.trim()) {
|
||||
setState({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nameToId = new Map(properties.map((p) => [p.name, p.id]));
|
||||
const raw = parseRaw(source);
|
||||
const resolved = resolve(raw, nameToId);
|
||||
const typeMap = new Map<string, FormulaResultType>(
|
||||
properties.map((p) => [p.id, clientResultTypeOf(p.type)]),
|
||||
);
|
||||
const tc = typecheck(resolved.ast, typeMap, registryForTypecheck);
|
||||
const candidate = {
|
||||
id: editingPropertyId ?? "pending",
|
||||
type: "formula" as const,
|
||||
typeOptions: { dependencies: resolved.dependencies },
|
||||
};
|
||||
const others = editingPropertyId
|
||||
? properties.filter((p) => p.id !== editingPropertyId)
|
||||
: properties;
|
||||
const graph = new BaseFormulaGraph([...others, candidate as any]);
|
||||
const cycle = graph.detectCycle(candidate as any);
|
||||
if (cycle) {
|
||||
setState({
|
||||
state: "error",
|
||||
code: "CYCLE",
|
||||
message: `Cycle: ${cycle.join(" \u2192 ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
state: "ok",
|
||||
resultType: tc.resultType,
|
||||
ast: resolved.ast,
|
||||
dependencies: resolved.dependencies,
|
||||
});
|
||||
} catch (e: any) {
|
||||
const first = e?.errors?.[0];
|
||||
setState({
|
||||
state: "error",
|
||||
code: first?.code ?? "PARSE_ERROR",
|
||||
message: first?.message ?? e?.message ?? String(e),
|
||||
span: first?.span,
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(handle);
|
||||
}, [deps]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function clientResultTypeOf(type: string): FormulaResultType {
|
||||
if (type === "number") return "number";
|
||||
if (
|
||||
type === "text" ||
|
||||
type === "url" ||
|
||||
type === "email" ||
|
||||
type === "longText"
|
||||
)
|
||||
return "string";
|
||||
if (type === "checkbox") return "boolean";
|
||||
if (type === "date" || type === "createdAt" || type === "lastEditedAt")
|
||||
return "date";
|
||||
return "null";
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { type RefObject, useEffect } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { unsafeOverflowAutoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element";
|
||||
import { COLUMN_DRAG_TYPE } from "@/ee/base/components/grid/grid-header-cell";
|
||||
import { ROW_DRAG_TYPE } from "@/ee/base/components/grid/grid-row";
|
||||
|
||||
const HEADER_BAND_REACH_PX = 60;
|
||||
const EDGE_OUTWARD_REACH_PX = 80;
|
||||
|
||||
const EARLY_PAN_MARGIN_PX = 100;
|
||||
const MIN_PAN_SPEED_PX = 3;
|
||||
const MAX_PAN_SPEED_PX = 16;
|
||||
|
||||
export function useGridAutoScroll<T extends HTMLElement>(
|
||||
bodyRef: RefObject<T | null>,
|
||||
pageId: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const element = bodyRef.current;
|
||||
if (!element) return;
|
||||
|
||||
let rafId = 0;
|
||||
let pointerX: number | null = null;
|
||||
// Captured once at drag start: cursor-to-column-left-edge distance and column width.
|
||||
let grabOffsetX = 0;
|
||||
let columnWidth = 0;
|
||||
|
||||
let lockedScrollLeft: number | null = null;
|
||||
const keepHorizontalScroll = () => {
|
||||
if (lockedScrollLeft !== null && element.scrollLeft !== lockedScrollLeft) {
|
||||
element.scrollLeft = lockedScrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
function speedForDepth(distanceFromEdge: number): number {
|
||||
const depth = Math.min(1, (EARLY_PAN_MARGIN_PX - distanceFromEdge) / EARLY_PAN_MARGIN_PX);
|
||||
return MIN_PAN_SPEED_PX + (MAX_PAN_SPEED_PX - MIN_PAN_SPEED_PX) * depth;
|
||||
}
|
||||
|
||||
function pan() {
|
||||
if (pointerX === null) {
|
||||
rafId = 0;
|
||||
return;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const columnLeft = pointerX - grabOffsetX;
|
||||
const columnRight = columnLeft + columnWidth;
|
||||
const fromLeft = columnLeft - rect.left;
|
||||
const fromRight = rect.right - columnRight;
|
||||
let delta = 0;
|
||||
if (fromLeft < EARLY_PAN_MARGIN_PX) {
|
||||
delta = -speedForDepth(fromLeft);
|
||||
} else if (fromRight < EARLY_PAN_MARGIN_PX) {
|
||||
delta = speedForDepth(fromRight);
|
||||
}
|
||||
if (delta !== 0) element.scrollLeft += delta;
|
||||
rafId = requestAnimationFrame(pan);
|
||||
}
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
canScroll: ({ source }) =>
|
||||
source.data?.type === COLUMN_DRAG_TYPE &&
|
||||
source.data?.pageId === pageId,
|
||||
getAllowedAxis: () => "horizontal" as const,
|
||||
}),
|
||||
unsafeOverflowAutoScrollForElements({
|
||||
element,
|
||||
canScroll: ({ source }) =>
|
||||
source.data?.type === COLUMN_DRAG_TYPE &&
|
||||
source.data?.pageId === pageId,
|
||||
getAllowedAxis: () => "horizontal" as const,
|
||||
getOverflow: () => ({
|
||||
forLeftEdge: { left: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
|
||||
forRightEdge: { right: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
|
||||
}),
|
||||
}),
|
||||
monitorForElements({
|
||||
canMonitor: ({ source }) =>
|
||||
source.data?.type === COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
|
||||
onDragStart: ({ location, source }) => {
|
||||
const cr = source.element.getBoundingClientRect();
|
||||
grabOffsetX = location.current.input.clientX - cr.left;
|
||||
columnWidth = cr.width;
|
||||
pointerX = location.current.input.clientX;
|
||||
if (rafId === 0) rafId = requestAnimationFrame(pan);
|
||||
},
|
||||
onDrag: ({ location }) => {
|
||||
pointerX = location.current.input.clientX;
|
||||
},
|
||||
onDrop: () => {
|
||||
pointerX = null;
|
||||
if (rafId !== 0) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
},
|
||||
}),
|
||||
monitorForElements({
|
||||
canMonitor: ({ source }) =>
|
||||
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
|
||||
onDragStart: () => {
|
||||
lockedScrollLeft = element.scrollLeft;
|
||||
element.addEventListener("scroll", keepHorizontalScroll);
|
||||
},
|
||||
onDrop: () => {
|
||||
element.removeEventListener("scroll", keepHorizontalScroll);
|
||||
lockedScrollLeft = null;
|
||||
},
|
||||
}),
|
||||
() => {
|
||||
if (rafId !== 0) cancelAnimationFrame(rafId);
|
||||
element.removeEventListener("scroll", keepHorizontalScroll);
|
||||
},
|
||||
);
|
||||
}, [bodyRef, pageId]);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
IBaseRow,
|
||||
IBaseProperty,
|
||||
EditingCell,
|
||||
FocusedCell,
|
||||
CellCoord,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { computeNextCell } from "@/ee/base/utils/grid-cell-nav";
|
||||
|
||||
type UseGridKeyboardNavOptions = {
|
||||
table: Table<IBaseRow>;
|
||||
properties: IBaseProperty[];
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
focusedCell: FocusedCell;
|
||||
setFocusedCell: (cell: FocusedCell) => void;
|
||||
editingCell: EditingCell;
|
||||
setEditingCell: (cell: EditingCell) => void;
|
||||
openEditor: (coord: CellCoord) => void;
|
||||
clearCell: (coord: CellCoord) => void;
|
||||
beginTypeToEdit: (coord: CellCoord, char: string) => void;
|
||||
scrollCellIntoView: (coord: CellCoord, rowIndex: number) => void;
|
||||
selectionCount: number;
|
||||
clearSelection: () => void;
|
||||
deleteSelected: () => void | Promise<void>;
|
||||
toggleRowSelection: (rowId: string) => void;
|
||||
expandRow: (rowId: string) => void;
|
||||
addRow: (afterRowId: string, focusPropertyId: string) => void;
|
||||
};
|
||||
|
||||
const isPrintableKey = (e: KeyboardEvent) =>
|
||||
e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
|
||||
|
||||
const isTextEntry = (el: Element | null) =>
|
||||
!!el &&
|
||||
(el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
(el as HTMLElement).isContentEditable);
|
||||
|
||||
export function useGridKeyboardNav({
|
||||
table,
|
||||
properties,
|
||||
containerRef,
|
||||
focusedCell,
|
||||
setFocusedCell,
|
||||
editingCell,
|
||||
setEditingCell,
|
||||
openEditor,
|
||||
clearCell,
|
||||
beginTypeToEdit,
|
||||
scrollCellIntoView,
|
||||
selectionCount,
|
||||
clearSelection,
|
||||
deleteSelected,
|
||||
toggleRowSelection,
|
||||
expandRow,
|
||||
addRow,
|
||||
}: UseGridKeyboardNavOptions) {
|
||||
const getColIds = useCallback(
|
||||
() =>
|
||||
table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number")
|
||||
.map((col) => col.id),
|
||||
[table],
|
||||
);
|
||||
|
||||
const getNavColIds = useCallback(
|
||||
() => table.getVisibleLeafColumns().map((col) => col.id),
|
||||
[table],
|
||||
);
|
||||
|
||||
const getRowIds = useCallback(
|
||||
() => table.getRowModel().rows.map((row) => row.id),
|
||||
[table],
|
||||
);
|
||||
|
||||
const propertyType = useCallback(
|
||||
(propertyId: string) => properties.find((p) => p.id === propertyId)?.type,
|
||||
[properties],
|
||||
);
|
||||
|
||||
const primaryPropertyId = useMemo(
|
||||
() => properties.find((p) => p.isPrimary)?.id,
|
||||
[properties],
|
||||
);
|
||||
|
||||
const goEditing = useCallback(
|
||||
(next: CellCoord) => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
setEditingCell(next);
|
||||
setFocusedCell(next);
|
||||
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
|
||||
},
|
||||
[setEditingCell, setFocusedCell, scrollCellIntoView, getRowIds],
|
||||
);
|
||||
|
||||
const goFocused = useCallback(
|
||||
(next: CellCoord) => {
|
||||
setFocusedCell(next);
|
||||
scrollCellIntoView(next, getRowIds().indexOf(next.rowId));
|
||||
},
|
||||
[setFocusedCell, scrollCellIntoView, getRowIds],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (editingCell) {
|
||||
const inInput = isTextEntry(e.target as Element);
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight": {
|
||||
if (inInput) return;
|
||||
e.preventDefault();
|
||||
const d =
|
||||
e.key === "ArrowUp"
|
||||
? [-1, 0]
|
||||
: e.key === "ArrowDown"
|
||||
? [1, 0]
|
||||
: e.key === "ArrowLeft"
|
||||
? [0, -1]
|
||||
: [0, 1];
|
||||
const next = computeNextCell(
|
||||
getRowIds(),
|
||||
getColIds(),
|
||||
editingCell,
|
||||
d[0],
|
||||
d[1],
|
||||
false,
|
||||
);
|
||||
if (next) goEditing(next);
|
||||
break;
|
||||
}
|
||||
case "Tab": {
|
||||
e.preventDefault();
|
||||
const next = computeNextCell(
|
||||
getRowIds(),
|
||||
getColIds(),
|
||||
editingCell,
|
||||
0,
|
||||
e.shiftKey ? -1 : 1,
|
||||
true,
|
||||
);
|
||||
if (next) goEditing(next);
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey && editingCell.propertyId === primaryPropertyId) {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
setEditingCell(null);
|
||||
addRow(editingCell.rowId, editingCell.propertyId);
|
||||
break;
|
||||
}
|
||||
const next = computeNextCell(
|
||||
getRowIds(),
|
||||
getColIds(),
|
||||
editingCell,
|
||||
1,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
setEditingCell(null);
|
||||
if (next) goFocused(next);
|
||||
else setFocusedCell(editingCell);
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
e.preventDefault();
|
||||
setEditingCell(null);
|
||||
setFocusedCell(editingCell);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target !== containerRef.current) return;
|
||||
|
||||
if (isTextEntry(document.activeElement)) return;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (selectionCount > 0) {
|
||||
e.preventDefault();
|
||||
clearSelection();
|
||||
} else if (focusedCell) {
|
||||
e.preventDefault();
|
||||
setFocusedCell(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (selectionCount > 0) {
|
||||
e.preventDefault();
|
||||
void deleteSelected();
|
||||
} else if (focusedCell) {
|
||||
e.preventDefault();
|
||||
clearCell(focusedCell);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!focusedCell) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
{
|
||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, -1, 0, false);
|
||||
if (next) goFocused(next);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
{
|
||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 1, 0, false);
|
||||
if (next) goFocused(next);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
{
|
||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, -1, false);
|
||||
if (next) goFocused(next);
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
{
|
||||
const next = computeNextCell(getRowIds(), getNavColIds(), focusedCell, 0, 1, false);
|
||||
if (next) goFocused(next);
|
||||
}
|
||||
break;
|
||||
case "Tab": {
|
||||
const next = computeNextCell(
|
||||
getRowIds(),
|
||||
getNavColIds(),
|
||||
focusedCell,
|
||||
0,
|
||||
e.shiftKey ? -1 : 1,
|
||||
true,
|
||||
);
|
||||
if (next) {
|
||||
e.preventDefault();
|
||||
goFocused(next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Enter":
|
||||
case "F2":
|
||||
e.preventDefault();
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
e.shiftKey &&
|
||||
focusedCell.propertyId === primaryPropertyId
|
||||
) {
|
||||
addRow(focusedCell.rowId, focusedCell.propertyId);
|
||||
} else if (focusedCell.propertyId === "__row_number") {
|
||||
toggleRowSelection(focusedCell.rowId);
|
||||
} else {
|
||||
openEditor(focusedCell);
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (focusedCell.propertyId === "__row_number") {
|
||||
toggleRowSelection(focusedCell.rowId);
|
||||
} else if (propertyType(focusedCell.propertyId) === "checkbox") {
|
||||
openEditor(focusedCell);
|
||||
} else {
|
||||
expandRow(focusedCell.rowId);
|
||||
}
|
||||
} else if (isPrintableKey(e)) {
|
||||
e.preventDefault();
|
||||
beginTypeToEdit(focusedCell, e.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
containerRef,
|
||||
editingCell,
|
||||
focusedCell,
|
||||
getRowIds,
|
||||
getColIds,
|
||||
getNavColIds,
|
||||
goEditing,
|
||||
goFocused,
|
||||
setEditingCell,
|
||||
setFocusedCell,
|
||||
openEditor,
|
||||
clearCell,
|
||||
beginTypeToEdit,
|
||||
propertyType,
|
||||
selectionCount,
|
||||
clearSelection,
|
||||
deleteSelected,
|
||||
toggleRowSelection,
|
||||
expandRow,
|
||||
primaryPropertyId,
|
||||
addRow,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("keydown", handleKeyDown);
|
||||
return () => el.removeEventListener("keydown", handleKeyDown);
|
||||
}, [containerRef, handleKeyDown]);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
// Keeps the header's scrollLeft in lockstep with the body's. Also converts
|
||||
// vertical wheel events on the header into horizontal scroll on the body.
|
||||
// Generic so callers can pass useRef<HTMLDivElement>(null) without a cast.
|
||||
export function useHorizontalScrollSync<
|
||||
TBody extends HTMLElement,
|
||||
THeader extends HTMLElement,
|
||||
>(
|
||||
bodyRef: RefObject<TBody | null>,
|
||||
headerRef: RefObject<THeader | null>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const body = bodyRef.current;
|
||||
const header = headerRef.current;
|
||||
if (!body || !header) return;
|
||||
|
||||
let rafId = 0;
|
||||
|
||||
const sync = () => {
|
||||
rafId = 0;
|
||||
header.scrollLeft = body.scrollLeft;
|
||||
};
|
||||
|
||||
const onBodyScroll = () => {
|
||||
if (rafId !== 0) return;
|
||||
rafId = requestAnimationFrame(sync);
|
||||
};
|
||||
|
||||
const onHeaderWheel = (e: WheelEvent) => {
|
||||
// Trackpad horizontal-dominant gestures deliver deltaX; let those
|
||||
// flow naturally. Convert vertical ticks into horizontal pan.
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
||||
if (e.deltaY === 0) return;
|
||||
// preventDefault suppresses the default vertical scroll (requires
|
||||
// non-passive listener, configured below).
|
||||
e.preventDefault();
|
||||
body.scrollLeft += e.deltaY;
|
||||
};
|
||||
|
||||
const onHeaderScroll = () => {
|
||||
if (rafId !== 0) return;
|
||||
if (body.scrollLeft !== header.scrollLeft) {
|
||||
body.scrollLeft = header.scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
body.addEventListener("scroll", onBodyScroll, { passive: true });
|
||||
header.addEventListener("scroll", onHeaderScroll, { passive: true });
|
||||
header.addEventListener("wheel", onHeaderWheel, { passive: false });
|
||||
|
||||
// Initial sync in case the body is already scrolled when the hook mounts.
|
||||
header.scrollLeft = body.scrollLeft;
|
||||
|
||||
return () => {
|
||||
body.removeEventListener("scroll", onBodyScroll);
|
||||
header.removeEventListener("scroll", onHeaderScroll);
|
||||
header.removeEventListener("wheel", onHeaderWheel);
|
||||
if (rafId !== 0) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [bodyRef, headerRef]);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type RefObject, useEffect } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { unsafeOverflowAutoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element";
|
||||
import { KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||
|
||||
const HEADER_BAND_REACH_PX = 60;
|
||||
const EDGE_OUTWARD_REACH_PX = 80;
|
||||
|
||||
export function useKanbanBoardAutoScroll<T extends HTMLElement>(
|
||||
boardRef: RefObject<T | null>,
|
||||
pageId: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const element = boardRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
|
||||
(source.data?.type === KANBAN_CARD_DRAG_TYPE ||
|
||||
source.data?.type === KANBAN_COLUMN_DRAG_TYPE) &&
|
||||
source.data?.pageId === pageId;
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
canScroll,
|
||||
getAllowedAxis: () => "horizontal" as const,
|
||||
}),
|
||||
unsafeOverflowAutoScrollForElements({
|
||||
element,
|
||||
canScroll,
|
||||
getAllowedAxis: () => "horizontal" as const,
|
||||
getOverflow: () => ({
|
||||
forLeftEdge: { left: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
|
||||
forRightEdge: { right: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}, [boardRef, pageId]);
|
||||
}
|
||||
|
||||
export function useKanbanColumnAutoScroll<T extends HTMLElement>(
|
||||
listRef: RefObject<T | null>,
|
||||
pageId: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const element = listRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
|
||||
source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId;
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
canScroll,
|
||||
getAllowedAxis: () => "vertical" as const,
|
||||
}),
|
||||
unsafeOverflowAutoScrollForElements({
|
||||
element,
|
||||
canScroll,
|
||||
getAllowedAxis: () => "vertical" as const,
|
||||
getOverflow: () => ({
|
||||
forTopEdge: { top: EDGE_OUTWARD_REACH_PX },
|
||||
forBottomEdge: { bottom: EDGE_OUTWARD_REACH_PX },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}, [listRef, pageId]);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||
import classes from "@/ee/base/styles/kanban.module.css";
|
||||
|
||||
export function useKanbanCardDnd({
|
||||
cardRef,
|
||||
rowId,
|
||||
columnKey,
|
||||
pageId,
|
||||
}: {
|
||||
cardRef: RefObject<HTMLDivElement | null>;
|
||||
rowId: string;
|
||||
columnKey: string;
|
||||
pageId: string;
|
||||
}): { closestEdge: Edge | null; isDragging: boolean } {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const cardEl = cardRef.current;
|
||||
if (!cardEl) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: cardEl,
|
||||
getInitialData: () => ({
|
||||
type: KANBAN_CARD_DRAG_TYPE,
|
||||
rowId,
|
||||
columnKey,
|
||||
pageId,
|
||||
}),
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
const width = cardEl.getBoundingClientRect().width;
|
||||
setCustomNativeDragPreview({
|
||||
nativeSetDragImage,
|
||||
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
|
||||
render: ({ container }) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = classes.cardDragPreview;
|
||||
card.style.width = `${width}px`;
|
||||
const clone = cardEl.cloneNode(true) as HTMLElement;
|
||||
clone.style.opacity = "1";
|
||||
card.appendChild(clone);
|
||||
container.appendChild(card);
|
||||
},
|
||||
});
|
||||
},
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: cardEl,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === KANBAN_CARD_DRAG_TYPE &&
|
||||
source.data.pageId === pageId,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ rowId, columnKey },
|
||||
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: () => setClosestEdge(null),
|
||||
}),
|
||||
);
|
||||
}, [cardRef, rowId, columnKey, pageId]);
|
||||
|
||||
return { closestEdge, isDragging };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { NO_VALUE_CHOICE_ID, type IBaseRow } from "@/ee/base/types/base.types";
|
||||
|
||||
export function resolveCardDrop(args: {
|
||||
draggedRowId: string;
|
||||
targetRowId: string | null;
|
||||
edge: "top" | "bottom" | null;
|
||||
targetColumnKey: string;
|
||||
sourceColumnKey: string;
|
||||
targetColumnRows: IBaseRow[];
|
||||
}): { columnChanged: boolean; destChoiceValue: string | null; position: string } | null {
|
||||
const { draggedRowId, targetRowId, edge, targetColumnKey, sourceColumnKey, targetColumnRows } = args;
|
||||
const columnChanged = sourceColumnKey !== targetColumnKey;
|
||||
if (!columnChanged && draggedRowId === targetRowId) return null;
|
||||
const destChoiceValue = targetColumnKey === NO_VALUE_CHOICE_ID ? null : targetColumnKey;
|
||||
const rows = targetColumnRows.filter((r) => r.id !== draggedRowId);
|
||||
let position: string;
|
||||
if (!targetRowId || edge === null) {
|
||||
const last = rows[rows.length - 1];
|
||||
position = generateJitteredKeyBetween(last?.position ?? null, null);
|
||||
} else {
|
||||
const idx = rows.findIndex((r) => r.id === targetRowId);
|
||||
if (idx === -1) {
|
||||
const last = rows[rows.length - 1];
|
||||
position = generateJitteredKeyBetween(last?.position ?? null, null);
|
||||
} else {
|
||||
const neighbor = edge === "top" ? idx - 1 : idx + 1;
|
||||
const lower = edge === "top" ? rows[neighbor]?.position ?? null : rows[idx].position;
|
||||
const upper = edge === "top" ? rows[idx].position : rows[neighbor]?.position ?? null;
|
||||
position = generateJitteredKeyBetween(lower, upper);
|
||||
}
|
||||
}
|
||||
return { columnChanged, destChoiceValue, position };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
|
||||
|
||||
export function useKanbanColumnDnd({
|
||||
headerRef,
|
||||
handleRef,
|
||||
columnKey,
|
||||
pageId,
|
||||
}: {
|
||||
headerRef: RefObject<HTMLDivElement | null>;
|
||||
handleRef: RefObject<HTMLDivElement | null>;
|
||||
columnKey: string;
|
||||
pageId: string;
|
||||
}): { closestEdge: Edge | null; isDragging: boolean } {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const headerEl = headerRef.current;
|
||||
const handleEl = handleRef.current;
|
||||
if (!headerEl || !handleEl) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: headerEl,
|
||||
dragHandle: handleEl,
|
||||
getInitialData: () => ({
|
||||
type: KANBAN_COLUMN_DRAG_TYPE,
|
||||
columnKey,
|
||||
pageId,
|
||||
}),
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: headerEl,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === KANBAN_COLUMN_DRAG_TYPE &&
|
||||
source.data.pageId === pageId &&
|
||||
source.data.columnKey !== columnKey,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ columnKey },
|
||||
{ input, element, allowedEdges: ["left", "right"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: () => setClosestEdge(null),
|
||||
}),
|
||||
);
|
||||
}, [headerRef, handleRef, columnKey, pageId]);
|
||||
|
||||
return { closestEdge, isDragging };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from "react";
|
||||
import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
|
||||
|
||||
export type KanbanGroup = KanbanColumn & { hidden: boolean };
|
||||
|
||||
export function useKanbanColumns(
|
||||
base: IBase | undefined,
|
||||
view: IBaseView | undefined,
|
||||
): {
|
||||
groupByPropertyId: string | undefined;
|
||||
columns: KanbanColumn[];
|
||||
allGroups: KanbanGroup[];
|
||||
hasValidGroupBy: boolean;
|
||||
} {
|
||||
return useMemo(() => {
|
||||
const groupByPropertyId = view?.config?.groupByPropertyId;
|
||||
const prop = groupByPropertyId ? base?.properties.find((p) => p.id === groupByPropertyId) : undefined;
|
||||
const groupable = prop && (prop.type === "select" || prop.type === "status");
|
||||
|
||||
if (!groupable || !prop || !view) {
|
||||
return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
|
||||
}
|
||||
|
||||
const typeOptions = prop.typeOptions as SelectTypeOptions;
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
const choiceMap = new Map(choices.map((c) => [c.id, c]));
|
||||
const validKeys = new Set([NO_VALUE_CHOICE_ID, ...choices.map((c) => c.id)]);
|
||||
|
||||
const config = view.config;
|
||||
const configChoiceOrder: string[] = config.choiceOrder?.length
|
||||
? config.choiceOrder.filter((k) => validKeys.has(k))
|
||||
: [...(typeOptions?.choiceOrder ?? choices.map((c) => c.id)), NO_VALUE_CHOICE_ID];
|
||||
|
||||
const inOrder = new Set(configChoiceOrder);
|
||||
const baseOrder = [
|
||||
...configChoiceOrder,
|
||||
...choices.map((c) => c.id).filter((id) => !inOrder.has(id)),
|
||||
];
|
||||
|
||||
const hidden = new Set(config.hiddenChoiceIds ?? []);
|
||||
const allGroups: KanbanGroup[] = baseOrder.map((k) => {
|
||||
if (k === NO_VALUE_CHOICE_ID) {
|
||||
return { key: k, name: "No value", color: undefined, isNoValue: true, hidden: hidden.has(k) };
|
||||
}
|
||||
const choice = choiceMap.get(k);
|
||||
return { key: k, name: choice?.name ?? k, color: choice?.color, isNoValue: false, hidden: hidden.has(k) };
|
||||
});
|
||||
const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
|
||||
|
||||
return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
|
||||
}, [base, view]);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type UseListKeyboardNavResult = {
|
||||
activeIndex: number;
|
||||
setActiveIndex: (idx: number) => void;
|
||||
handleNavKey: (e: React.KeyboardEvent) => boolean;
|
||||
setOptionRef: (idx: number) => (el: HTMLElement | null) => void;
|
||||
};
|
||||
|
||||
export function useListKeyboardNav(
|
||||
itemCount: number,
|
||||
resetDeps: ReadonlyArray<unknown>,
|
||||
): UseListKeyboardNavResult {
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const optionRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setActiveIndex(-1); }, resetDeps);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
const el = optionRefs.current[activeIndex];
|
||||
if (el) el.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const setOptionRef = useCallback(
|
||||
(idx: number) => (el: HTMLElement | null) => {
|
||||
optionRefs.current[idx] = el;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNavKey = useCallback(
|
||||
(e: React.KeyboardEvent): boolean => {
|
||||
if (itemCount === 0) return false;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((idx) => (idx < itemCount - 1 ? idx + 1 : 0));
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((idx) => (idx <= 0 ? itemCount - 1 : idx - 1));
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setActiveIndex(0);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
setActiveIndex(itemCount - 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[itemCount],
|
||||
);
|
||||
|
||||
return { activeIndex, setActiveIndex, handleNavKey, setOptionRef };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { searchSuggestions } from "@/features/search/services/search-service";
|
||||
|
||||
export type PersonSuggestion = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export function usePersonSearch(
|
||||
search: string,
|
||||
enabled: boolean,
|
||||
): PersonSuggestion[] {
|
||||
const [debounced] = useDebouncedValue(search, 250);
|
||||
const trimmed = debounced.trim();
|
||||
const { data = [] } = useQuery({
|
||||
queryKey: ["bases", "persons", "search", trimmed],
|
||||
queryFn: async () => {
|
||||
const res = await searchSuggestions({
|
||||
query: trimmed,
|
||||
includeUsers: true,
|
||||
limit: trimmed ? 25 : 10,
|
||||
});
|
||||
return (res.users ?? []) as PersonSuggestion[];
|
||||
},
|
||||
enabled,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
autoScrollForElements,
|
||||
autoScrollWindowForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { ROW_DRAG_TYPE } from "@/ee/base/components/grid/grid-row";
|
||||
|
||||
export function useRowAutoScroll(
|
||||
scrollElement: HTMLElement | Window | null,
|
||||
pageId: string,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!scrollElement) return;
|
||||
if (scrollElement === window) {
|
||||
return autoScrollWindowForElements({
|
||||
canScroll: ({ source }) =>
|
||||
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
|
||||
getAllowedAxis: () => "vertical" as const,
|
||||
});
|
||||
}
|
||||
return autoScrollForElements({
|
||||
element: scrollElement as HTMLElement,
|
||||
canScroll: ({ source }) =>
|
||||
source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
|
||||
getAllowedAxis: () => "vertical" as const,
|
||||
});
|
||||
}, [scrollElement, pageId]);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
const PARAM = "row";
|
||||
const BASE_PARAM = "rowBase";
|
||||
|
||||
export function useRowDetailModal(baseId: string) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rowParam = searchParams.get(PARAM);
|
||||
const openRowId =
|
||||
rowParam && searchParams.get(BASE_PARAM) === baseId ? rowParam : null;
|
||||
|
||||
const openRow = useCallback(
|
||||
(rowId: string, options?: { replace?: boolean }) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set(PARAM, rowId);
|
||||
next.set(BASE_PARAM, baseId);
|
||||
// Prev/next inside the modal replaces the entry so Back leaves the
|
||||
// modal instead of replaying every visited record.
|
||||
setSearchParams(next, { replace: options?.replace ?? false });
|
||||
},
|
||||
[searchParams, setSearchParams, baseId],
|
||||
);
|
||||
|
||||
const closeRow = useCallback(() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete(PARAM);
|
||||
next.delete(BASE_PARAM);
|
||||
setSearchParams(next, { replace: false });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
return { openRowId, openRow, closeRow };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
selectedRowIdsAtomFamily,
|
||||
lastToggledRowIndexAtomFamily,
|
||||
} from "@/ee/base/atoms/base-atoms";
|
||||
|
||||
type ToggleOpts = {
|
||||
shiftKey: boolean;
|
||||
rowIndex: number;
|
||||
orderedRowIds: string[];
|
||||
};
|
||||
|
||||
export function useRowSelection(pageId: string) {
|
||||
const [selectedIds, setSelectedIds] = useAtom(
|
||||
selectedRowIdsAtomFamily(pageId),
|
||||
) as unknown as [
|
||||
Set<string>,
|
||||
(val: Set<string> | ((prev: Set<string>) => Set<string>)) => void,
|
||||
];
|
||||
const [lastToggledIndex, setLastToggledIndex] = useAtom(
|
||||
lastToggledRowIndexAtomFamily(pageId),
|
||||
) as unknown as [number | null, (val: number | null) => void];
|
||||
|
||||
const isSelected = useCallback(
|
||||
(rowId: string) => selectedIds.has(rowId),
|
||||
[selectedIds],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(rowId: string, opts: ToggleOpts) => {
|
||||
const { shiftKey, rowIndex, orderedRowIds } = opts;
|
||||
const next = new Set(selectedIds);
|
||||
|
||||
if (shiftKey && lastToggledIndex !== null && lastToggledIndex !== rowIndex) {
|
||||
const start = Math.min(lastToggledIndex, rowIndex);
|
||||
const end = Math.max(lastToggledIndex, rowIndex);
|
||||
const anchorId = orderedRowIds[lastToggledIndex];
|
||||
const turnOn = anchorId ? next.has(anchorId) : true;
|
||||
for (let i = start; i <= end; i += 1) {
|
||||
const id = orderedRowIds[i];
|
||||
if (!id) continue;
|
||||
if (turnOn) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
} else {
|
||||
if (next.has(rowId)) next.delete(rowId);
|
||||
else next.add(rowId);
|
||||
}
|
||||
|
||||
setSelectedIds(next);
|
||||
setLastToggledIndex(rowIndex);
|
||||
},
|
||||
[selectedIds, lastToggledIndex, setSelectedIds, setLastToggledIndex],
|
||||
);
|
||||
|
||||
const toggleAll = useCallback(
|
||||
(loadedRowIds: string[]) => {
|
||||
if (loadedRowIds.length === 0) return;
|
||||
const allSelected = loadedRowIds.every((id) => selectedIds.has(id));
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(loadedRowIds));
|
||||
}
|
||||
setLastToggledIndex(null);
|
||||
},
|
||||
[selectedIds, setSelectedIds, setLastToggledIndex],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
setLastToggledIndex(null);
|
||||
}, [setSelectedIds, setLastToggledIndex]);
|
||||
|
||||
const removeIds = useCallback(
|
||||
(rowIds: string[]) => {
|
||||
if (rowIds.length === 0) return;
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
let changed = false;
|
||||
const next = new Set(prev);
|
||||
for (const id of rowIds) {
|
||||
if (next.delete(id)) changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
[setSelectedIds],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
selectionCount: selectedIds.size,
|
||||
isSelected,
|
||||
toggle,
|
||||
toggleAll,
|
||||
clear,
|
||||
removeIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { RESET } from "jotai/utils";
|
||||
import {
|
||||
BaseViewDraft,
|
||||
FilterGroup,
|
||||
ViewConfig,
|
||||
ViewConfigPatch,
|
||||
ViewSortConfig,
|
||||
} from "@/ee/base/types/base.types";
|
||||
import { viewDraftAtomFamily } from "@/ee/base/atoms/view-draft-atom";
|
||||
|
||||
export type UseViewDraftArgs = {
|
||||
userId: string | undefined;
|
||||
pageId: string | undefined;
|
||||
viewId: string | undefined;
|
||||
baselineFilter: FilterGroup | undefined;
|
||||
baselineSorts: ViewSortConfig[] | undefined;
|
||||
};
|
||||
|
||||
export type ViewDraftState = {
|
||||
draft: BaseViewDraft | null;
|
||||
effectiveFilter: FilterGroup | undefined;
|
||||
effectiveSorts: ViewSortConfig[] | undefined;
|
||||
isDirty: boolean;
|
||||
setFilter: (filter: FilterGroup | undefined) => void;
|
||||
setSorts: (sorts: ViewSortConfig[] | undefined) => void;
|
||||
reset: () => void;
|
||||
buildPromotedConfig: (baseline: ViewConfig) => ViewConfigPatch;
|
||||
};
|
||||
|
||||
// JSON-stringify equality suffices for FilterGroup and ViewSortConfig[]: both
|
||||
// are pure data trees with stable insertion order, so the same graph always
|
||||
// serializes identically. Avoids a deep-equal dependency for two simple types.
|
||||
function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
function sortsEq(
|
||||
a: ViewSortConfig[] | undefined,
|
||||
b: ViewSortConfig[] | undefined,
|
||||
) {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
|
||||
export function useViewDraft(args: UseViewDraftArgs): ViewDraftState {
|
||||
const { userId, pageId, viewId, baselineFilter, baselineSorts } = args;
|
||||
const ready = !!(userId && pageId && viewId);
|
||||
|
||||
// Always mount with a stable key so hook order is consistent. Not read/written when not ready.
|
||||
const atomKey = useMemo(
|
||||
() => ({
|
||||
userId: userId ?? "",
|
||||
pageId: pageId ?? "",
|
||||
viewId: viewId ?? "",
|
||||
}),
|
||||
[userId, pageId, viewId],
|
||||
);
|
||||
const [storedDraft, setDraft] = useAtom(viewDraftAtomFamily(atomKey));
|
||||
|
||||
const draft = ready ? storedDraft : null;
|
||||
|
||||
const setFilter = useCallback(
|
||||
(next: FilterGroup | undefined) => {
|
||||
if (!ready) return;
|
||||
const current = storedDraft ?? null;
|
||||
// If a baseline filter exists, clearing to undefined would fall back to it
|
||||
// in effectiveFilter. Persist an empty AND-group to explicitly override it.
|
||||
const mergedFilter =
|
||||
next === undefined && baselineFilter !== undefined
|
||||
? ({ op: "and", children: [] } as FilterGroup)
|
||||
: next;
|
||||
const mergedSorts = current?.sorts;
|
||||
if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) {
|
||||
setDraft(RESET);
|
||||
return;
|
||||
}
|
||||
setDraft({
|
||||
filter: mergedFilter,
|
||||
sorts: mergedSorts,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[ready, storedDraft, setDraft, baselineFilter],
|
||||
);
|
||||
|
||||
const setSorts = useCallback(
|
||||
(next: ViewSortConfig[] | undefined) => {
|
||||
if (!ready) return;
|
||||
const current = storedDraft ?? null;
|
||||
const mergedFilter = current?.filter;
|
||||
// If baseline sorts exist, clearing to undefined would fall back to them.
|
||||
// Persist an empty array to explicitly override with no sorts.
|
||||
const mergedSorts =
|
||||
next === undefined && baselineSorts !== undefined && baselineSorts.length > 0
|
||||
? []
|
||||
: next;
|
||||
if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) {
|
||||
setDraft(RESET);
|
||||
return;
|
||||
}
|
||||
setDraft({
|
||||
filter: mergedFilter,
|
||||
sorts: mergedSorts,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[ready, storedDraft, setDraft, baselineSorts],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (!ready) return;
|
||||
setDraft(RESET);
|
||||
}, [ready, setDraft]);
|
||||
|
||||
const effectiveFilter = useMemo(
|
||||
() => (draft?.filter !== undefined ? draft.filter : baselineFilter),
|
||||
[draft?.filter, baselineFilter],
|
||||
);
|
||||
const effectiveSorts = useMemo(
|
||||
() => (draft?.sorts !== undefined ? draft.sorts : baselineSorts),
|
||||
[draft?.sorts, baselineSorts],
|
||||
);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!draft) return false;
|
||||
const filterDirty =
|
||||
draft.filter !== undefined && !filterEq(draft.filter, baselineFilter);
|
||||
const sortsDirty =
|
||||
draft.sorts !== undefined && !sortsEq(draft.sorts, baselineSorts);
|
||||
return filterDirty || sortsDirty;
|
||||
}, [draft, baselineFilter, baselineSorts]);
|
||||
|
||||
const buildPromotedConfig = useCallback(
|
||||
(baseline: ViewConfig): ViewConfigPatch => ({
|
||||
filter: draft?.filter ?? baseline.filter ?? null,
|
||||
sorts: draft?.sorts ?? baseline.sorts ?? null,
|
||||
}),
|
||||
[draft],
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
return {
|
||||
draft: null,
|
||||
effectiveFilter: baselineFilter,
|
||||
effectiveSorts: baselineSorts,
|
||||
isDirty: false,
|
||||
setFilter: () => {},
|
||||
setSorts: () => {},
|
||||
reset: () => {},
|
||||
buildPromotedConfig: (baseline) => ({
|
||||
filter: baseline.filter ?? null,
|
||||
sorts: baseline.sorts ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
draft,
|
||||
effectiveFilter,
|
||||
effectiveSorts,
|
||||
isDirty,
|
||||
setFilter,
|
||||
setSorts,
|
||||
reset,
|
||||
buildPromotedConfig,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user