feat: kanban view

This commit is contained in:
Philipinho
2026-06-13 20:04:33 +01:00
parent 572452c80b
commit cb16ba11e1
52 changed files with 2918 additions and 312 deletions
+2
View File
@@ -28,6 +28,8 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json
# Copy packages # Copy packages
COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist
COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json
# Copy root package files # Copy root package files
COPY --from=builder /app/package.json /app/package.json COPY --from=builder /app/package.json /app/package.json
@@ -7,6 +7,8 @@ import {
IconEye, IconEye,
IconDownload, IconDownload,
IconArrowsDiagonal, IconArrowsDiagonal,
IconLayoutColumns,
IconAdjustments,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { import {
@@ -23,19 +25,20 @@ import { ViewTabs } from "@/ee/base/components/views/view-tabs";
import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config"; import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config";
import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config"; import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config";
import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility"; import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility";
import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker";
import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classes from "@/ee/base/styles/grid.module.css"; import classes from "@/ee/base/styles/grid.module.css";
import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css"; import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css";
type BaseToolbarProps = { type BaseToolbarProps = {
base: IBase; base: IBase;
// Effective view (baseline merged with local draft). Badge counts and popover
// seed data read from this; the real baseline only enters via the draft callbacks.
activeView: IBaseView | undefined; activeView: IBaseView | undefined;
views: IBaseView[]; views: IBaseView[];
table: Table<IBaseRow>; table?: Table<IBaseRow>;
onViewChange: (viewId: string) => void; onViewChange: (viewId: string) => void;
onAddView?: () => void; onAddView?: () => void;
canAddView?: boolean;
onPersistViewConfig: () => void; onPersistViewConfig: () => void;
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void; onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
onDraftFiltersChange: (filter: FilterGroup | undefined) => void; onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
@@ -50,6 +53,7 @@ export function BaseToolbar({
table, table,
onViewChange, onViewChange,
onAddView, onAddView,
canAddView,
onPersistViewConfig, onPersistViewConfig,
onDraftSortsChange, onDraftSortsChange,
onDraftFiltersChange, onDraftFiltersChange,
@@ -61,8 +65,11 @@ export function BaseToolbar({
const [sortOpened, setSortOpened] = useState(false); const [sortOpened, setSortOpened] = useState(false);
const [filterOpened, setFilterOpened] = useState(false); const [filterOpened, setFilterOpened] = useState(false);
const [propertiesOpened, setPropertiesOpened] = useState(false); const [propertiesOpened, setPropertiesOpened] = useState(false);
const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const isKanban = activeView?.type === "kanban";
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
if (exporting) return; if (exporting) return;
setExporting(true); setExporting(true);
@@ -85,8 +92,6 @@ export function BaseToolbar({
}, []); }, []);
const sorts = activeView?.config?.sorts ?? []; const sorts = activeView?.config?.sorts ?? [];
// The stored filter is a tree; the popover edits an AND-only flat list.
// Unwrap the top-level group's children when reading, rewrap on save.
const conditions = useMemo<FilterCondition[]>(() => { const conditions = useMemo<FilterCondition[]>(() => {
const filter = activeView?.config?.filter; const filter = activeView?.config?.filter;
if (!filter || filter.op !== "and") return []; if (!filter || filter.op !== "and") return [];
@@ -96,13 +101,13 @@ export function BaseToolbar({
}, [activeView?.config?.filter]); }, [activeView?.config?.filter]);
const hiddenPropertyCount = useMemo(() => { const hiddenPropertyCount = useMemo(() => {
if (!table) return 0;
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number"); const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length; return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
}, [table, table.getState().columnVisibility]); }, [table, table?.getState().columnVisibility]);
const handleSortsChange = useCallback( const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => { (newSorts: ViewSortConfig[]) => {
// Normalize empty to undefined so the draft hook drops the sorts axis.
onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined);
}, },
[onDraftSortsChange], [onDraftSortsChange],
@@ -110,7 +115,6 @@ export function BaseToolbar({
const handleFiltersChange = useCallback( const handleFiltersChange = useCallback(
(newConditions: FilterCondition[]) => { (newConditions: FilterCondition[]) => {
// Wrap the AND-flat list into the engine's FilterGroup shape; undefined drops the axis.
const filter: FilterGroup | undefined = const filter: FilterGroup | undefined =
newConditions.length > 0 newConditions.length > 0
? { op: "and", children: newConditions } ? { op: "and", children: newConditions }
@@ -128,6 +132,8 @@ export function BaseToolbar({
pageId={base.id} pageId={base.id}
onViewChange={onViewChange} onViewChange={onViewChange}
onAddView={onAddView} onAddView={onAddView}
base={base}
canAddView={canAddView}
getViewShareUrl={getViewShareUrl} getViewShareUrl={getViewShareUrl}
/> />
@@ -175,63 +181,104 @@ export function BaseToolbar({
</Tooltip> </Tooltip>
</ViewFilterConfigPopover> </ViewFilterConfigPopover>
<ViewSortConfigPopover {isKanban && activeView && (
opened={sortOpened} <>
onClose={() => setSortOpened(false)} <KanbanGroupByPicker base={base} view={activeView} pageId={base.id}>
sorts={sorts} <Tooltip label={t("Group by")}>
properties={base.properties} <ActionIcon
onChange={handleSortsChange} variant="subtle"
> size="sm"
<Tooltip label={t("Sort")}> color="gray"
<ActionIcon
variant="subtle"
size="sm"
color={sorts.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("sort")}
>
<IconSortAscending size={16} />
{sorts.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
> >
{sorts.length} <IconLayoutColumns size={16} />
</Badge> </ActionIcon>
)} </Tooltip>
</ActionIcon> </KanbanGroupByPicker>
</Tooltip>
</ViewSortConfigPopover>
<ViewPropertyVisibility <KanbanCardProperties
opened={propertiesOpened} opened={cardPropertiesOpened}
onClose={() => setPropertiesOpened(false)} onClose={() => setCardPropertiesOpened(false)}
table={table} base={base}
properties={base.properties} view={activeView}
onPersist={onPersistViewConfig} pageId={base.id}
>
<Tooltip label={t("Hide properties")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("properties")}
> >
<IconEye size={16} /> <Tooltip label={t("Card properties")}>
{hiddenPropertyCount > 0 && ( <ActionIcon
<Badge variant="subtle"
size="xs" size="sm"
circle color="gray"
color="blue" onClick={() => setCardPropertiesOpened((v) => !v)}
className={toolbarClasses.badgeDot}
> >
{hiddenPropertyCount} <IconAdjustments size={16} />
</Badge> </ActionIcon>
)} </Tooltip>
</ActionIcon> </KanbanCardProperties>
</Tooltip> </>
</ViewPropertyVisibility> )}
{!isKanban && (
<>
<ViewSortConfigPopover
opened={sortOpened}
onClose={() => setSortOpened(false)}
sorts={sorts}
properties={base.properties}
onChange={handleSortsChange}
>
<Tooltip label={t("Sort")}>
<ActionIcon
variant="subtle"
size="sm"
color={sorts.length > 0 ? "blue" : "gray"}
onClick={() => openToolbar("sort")}
>
<IconSortAscending size={16} />
{sorts.length > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{sorts.length}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewSortConfigPopover>
{table && (
<ViewPropertyVisibility
opened={propertiesOpened}
onClose={() => setPropertiesOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
<Tooltip label={t("Hide properties")}>
<ActionIcon
variant="subtle"
size="sm"
color={hiddenPropertyCount > 0 ? "blue" : "gray"}
onClick={() => openToolbar("properties")}
>
<IconEye size={16} />
{hiddenPropertyCount > 0 && (
<Badge
size="xs"
circle
color="blue"
className={toolbarClasses.badgeDot}
>
{hiddenPropertyCount}
</Badge>
)}
</ActionIcon>
</Tooltip>
</ViewPropertyVisibility>
)}
</>
)}
{onExpand && ( {onExpand && (
<Tooltip label={t("Open as page")}> <Tooltip label={t("Open as page")}>
@@ -22,10 +22,7 @@ import {
useUpdateRowMutation, useUpdateRowMutation,
useReorderRowMutation, useReorderRowMutation,
} from "@/ee/base/queries/base-row-query"; } from "@/ee/base/queries/base-row-query";
import { import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
useCreateViewMutation,
useUpdateViewMutation,
} from "@/ee/base/queries/base-view-query";
import { import {
activeViewIdAtomFamily, activeViewIdAtomFamily,
editingCellAtomFamily, editingCellAtomFamily,
@@ -51,6 +48,7 @@ import { getAppUrl } from "@/lib/config.ts";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import classes from "@/ee/base/styles/grid.module.css"; import classes from "@/ee/base/styles/grid.module.css";
import viewClasses from "@/ee/base/styles/base-view.module.css"; import viewClasses from "@/ee/base/styles/base-view.module.css";
import kanbanClasses from "@/ee/base/styles/kanban.module.css";
type BaseViewProps = { type BaseViewProps = {
pageId: string; pageId: string;
@@ -146,13 +144,15 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
// Gate on base to avoid a "bland" list request before the active view's // 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. // config resolves, which would double network traffic for sorted/filtered views.
const isKanban = activeView?.type === "kanban";
const { const {
data: rowsData, data: rowsData,
isLoading: rowsLoading, isLoading: rowsLoading,
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
} = useBaseRowsQuery(base ? pageId : undefined, activeFilter, activeSorts); } = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts);
// Warm the count cache alongside the rows query. Gate on currentUser so // Warm the count cache alongside the rows query. Gate on currentUser so
// useViewDraft has hydrated from localStorage before the count fires. // useViewDraft has hydrated from localStorage before the count fires.
@@ -162,7 +162,6 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
const updateRowMutation = useUpdateRowMutation(); const updateRowMutation = useUpdateRowMutation();
const createRowMutation = useCreateRowMutation(); const createRowMutation = useCreateRowMutation();
const reorderRowMutation = useReorderRowMutation(); const reorderRowMutation = useReorderRowMutation();
const createViewMutation = useCreateViewMutation();
const updateViewMutation = useUpdateViewMutation(); const updateViewMutation = useUpdateViewMutation();
useEffect(() => { useEffect(() => {
@@ -263,15 +262,6 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
[setActiveViewId], [setActiveViewId],
); );
const handleAddView = useCallback(() => {
if (!editable) return;
createViewMutation.mutate({
pageId,
name: t("New view"),
type: "table",
});
}, [editable, pageId, createViewMutation, t]);
const handleColumnReorder = useCallback( const handleColumnReorder = useCallback(
(columnId: string, finishIndex: number) => { (columnId: string, finishIndex: number) => {
const order = table.getState().columnOrder; const order = table.getState().columnOrder;
@@ -374,7 +364,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
[editable, pageId, reorderRow], [editable, pageId, reorderRow],
); );
if (baseLoading || rowsLoading) { if (baseLoading || (!isKanban && rowsLoading)) {
return <BaseTableSkeleton />; return <BaseTableSkeleton />;
} }
if (baseError) { if (baseError) {
@@ -407,7 +397,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
views={views} views={views}
table={table} table={table}
onViewChange={handleViewChange} onViewChange={handleViewChange}
onAddView={editable ? handleAddView : undefined} canAddView={editable}
onPersistViewConfig={guardedPersistViewConfig} onPersistViewConfig={guardedPersistViewConfig}
onDraftSortsChange={handleDraftSortsChange} onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange} onDraftFiltersChange={handleDraftFiltersChange}
@@ -416,38 +406,71 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
/> />
); );
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 (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; // Banner and toolbar go into aboveBand so they scroll with the host document;
// only the column-header row stays pinned (via --sticky-band-top). // only the column-header row stays pinned (via --sticky-band-top).
return ( return (
<BaseEditableProvider editable={editable}> <BaseEditableProvider editable={editable}>
<RowExpandProvider value={handleExpandRow}> <RowExpandProvider value={handleExpandRow}>
<ViewRenderer {viewRenderer(
base={base} <>
rows={rows} {banner}
effectiveView={effectiveView} {toolbar}
table={table} <BaseEmbedTitle pageId={pageId} />
pageId={pageId} </>,
embedded={embedded} )}
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}
aboveBand={
<>
{banner}
{toolbar}
<BaseEmbedTitle pageId={pageId} />
</>
}
/>
</RowExpandProvider> </RowExpandProvider>
<RowDetailModal <RowDetailModal
base={base} base={base}
@@ -460,6 +483,26 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
); );
} }
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 // Standalone: title, banner, and toolbar go in aboveBand inside the scroll
// container so they scroll away; only the column-header row stays pinned. // container so they scroll away; only the column-header row stays pinned.
return ( return (
@@ -467,32 +510,13 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
<div className={viewClasses.fullHeight}> <div className={viewClasses.fullHeight}>
<div className={classes.tableScrollport} ref={scrollportRef}> <div className={classes.tableScrollport} ref={scrollportRef}>
<RowExpandProvider value={handleExpandRow}> <RowExpandProvider value={handleExpandRow}>
<ViewRenderer {viewRenderer(
base={base} <>
rows={rows} {titleSlot}
effectiveView={effectiveView} {banner}
table={table} {toolbar}
pageId={pageId} </>,
embedded={embedded} )}
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}
aboveBand={
<>
{titleSlot}
{banner}
{toolbar}
</>
}
/>
</RowExpandProvider> </RowExpandProvider>
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
import { IBaseProperty } from "@/ee/base/types/base.types"; import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css"; import cellClasses from "@/ee/base/styles/cells.module.css";
type CellCreatedAtProps = { type CellCreatedAtProps = {
@@ -10,21 +11,8 @@ type CellCreatedAtProps = {
onCancel: () => void; onCancel: () => void;
}; };
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellCreatedAt({ value }: CellCreatedAtProps) { export function CellCreatedAt({ value }: CellCreatedAtProps) {
const formatted = formatTimestamp(value); const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) { if (!formatted) {
return <span className={cellClasses.emptyValue} />; return <span className={cellClasses.emptyValue} />;
@@ -1,4 +1,5 @@
import { IBaseProperty } from "@/ee/base/types/base.types"; import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatTimestamp } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css"; import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLastEditedAtProps = { type CellLastEditedAtProps = {
@@ -10,21 +11,8 @@ type CellLastEditedAtProps = {
onCancel: () => void; onCancel: () => void;
}; };
function formatTimestamp(val: unknown): string {
if (typeof val !== "string" || !val) return "";
const date = new Date(val);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function CellLastEditedAt({ value }: CellLastEditedAtProps) { export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
const formatted = formatTimestamp(value); const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) { if (!formatted) {
return <span className={cellClasses.emptyValue} />; return <span className={cellClasses.emptyValue} />;
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core"; import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
import { IBaseProperty } from "@/ee/base/types/base.types"; import { IBaseProperty } from "@/ee/base/types/base.types";
import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters";
import cellClasses from "@/ee/base/styles/cells.module.css"; import cellClasses from "@/ee/base/styles/cells.module.css";
type CellLongTextProps = { type CellLongTextProps = {
@@ -68,7 +69,7 @@ export function CellLongText({
onCancel(); onCancel();
}; };
const preview = toText(value).replace(/\s+/g, " ").trim(); const preview = formatLongTextPreview(toText(value));
return ( return (
<Popover <Popover
@@ -5,14 +5,13 @@ import clsx from "clsx";
import { import {
IBaseProperty, IBaseProperty,
PersonTypeOptions, PersonTypeOptions,
UserRef,
} from "@/ee/base/types/base.types"; } from "@/ee/base/types/base.types";
import { import {
useReferenceStore, useReferenceStore,
useHydrateUsers, useHydrateUsers,
} from "@/ee/base/reference/reference-store"; } from "@/ee/base/reference/reference-store";
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow"; import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
import cellClasses from "@/ee/base/styles/cells.module.css"; import cellClasses from "@/ee/base/styles/cells.module.css";
import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav"; import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
import { usePersonSearch } from "@/ee/base/hooks/use-person-search"; import { usePersonSearch } from "@/ee/base/hooks/use-person-search";
@@ -245,38 +244,3 @@ export function CellPerson({
return <PersonReadList personIds={personIds} users={store.users} />; return <PersonReadList personIds={personIds} users={store.users} />;
} }
function PersonReadList({
personIds,
users,
}: {
personIds: string[];
users: Record<string, UserRef>;
}) {
const entries = personIds.map((id) => ({
id,
name: users[id]?.name ?? id.substring(0, 8),
avatarUrl: users[id]?.avatarUrl ?? "",
}));
const chips = entries.map((entry) => (
<span
key={entry.id}
className={clsx(cellClasses.badge, cellClasses.personChip)}
>
<CustomAvatar
avatarUrl={entry.avatarUrl}
name={entry.name}
size={16}
radius="xl"
style={{ flexShrink: 0 }}
/>
<span className={cellClasses.personChipName}>{entry.name}</span>
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
tooltipLabel={entries.map((e) => e.name).join(", ")}
/>
);
}
@@ -2,7 +2,7 @@ import { CSSProperties } from "react";
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = { const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" }, gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#c92a2a", textDark: "#ffa8a8" }, red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#bf2020", textDark: "#ffa8a8" },
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" }, pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" }, grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" }, violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
@@ -0,0 +1,40 @@
import clsx from "clsx";
import { UserRef } from "@/ee/base/types/base.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import cellClasses from "@/ee/base/styles/cells.module.css";
type PersonReadListProps = {
personIds: string[];
users: Record<string, UserRef>;
};
export function PersonReadList({ personIds, users }: PersonReadListProps) {
const entries = personIds.map((id) => ({
id,
name: users[id]?.name ?? id.substring(0, 8),
avatarUrl: users[id]?.avatarUrl ?? "",
}));
const chips = entries.map((entry) => (
<span
key={entry.id}
className={clsx(cellClasses.badge, cellClasses.personChip)}
>
<CustomAvatar
avatarUrl={entry.avatarUrl}
name={entry.name}
size={16}
radius="xl"
style={{ flexShrink: 0 }}
/>
<span className={cellClasses.personChipName}>{entry.name}</span>
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={entries.map((e) => `${e.id}:${e.name}`).join("|")}
tooltipLabel={entries.map((e) => e.name).join(", ")}
/>
);
}
@@ -0,0 +1,219 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { extractClosestEdge, type Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { IBase, IBaseRow, IBaseView, FilterGroup, KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useKanbanMoveCardMutation, useBaseRowGroupCountsQuery } from "@/ee/base/queries/base-row-query";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { resolveCardDrop } from "@/ee/base/hooks/use-kanban-card-drop";
import { useKanbanBoardAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
import { KanbanColumn } from "@/ee/base/components/kanban/kanban-column";
import { KanbanEmptyState } from "@/ee/base/components/kanban/kanban-empty-state";
import classes from "@/ee/base/styles/kanban.module.css";
type BaseKanbanProps = {
base: IBase;
view: IBaseView;
pageId: string;
embedded?: boolean;
editable: boolean;
viewFilter: FilterGroup | undefined;
};
export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
const { t } = useTranslation();
const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
const updateView = useUpdateViewMutation();
const moveCard = useKanbanMoveCardMutation();
const { openRow } = useRowDetailModal();
const { data: groupCounts } = useBaseRowGroupCountsQuery(pageId, groupByPropertyId, viewFilter, undefined);
const openRowRef = useRef(openRow);
useLayoutEffect(() => { openRowRef.current = openRow; });
const handleOpenRow = useCallback((id: string) => openRowRef.current(id), []);
const boardRef = useRef<HTMLDivElement>(null);
useKanbanBoardAutoScroll(boardRef, pageId);
const cardRefs = useRef<Map<string, { columnKey: string; el: HTMLDivElement }>>(new Map());
const registerCardRef = useCallback((rowId: string, columnKey: string, el: HTMLDivElement | null) => {
if (el) {
cardRefs.current.set(rowId, { columnKey, el });
} else {
cardRefs.current.delete(rowId);
}
}, []);
const columnRows = useRef<Map<string, IBaseRow[]>>(new Map());
const registerColumnRows = useCallback((key: string, rows: IBaseRow[]) => {
columnRows.current.set(key, rows);
}, []);
const hideColumn = useCallback(
(key: string) => {
const next = Array.from(new Set([...(view.config?.hiddenChoiceIds ?? []), key]));
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
},
[updateView, view.id, view.config?.hiddenChoiceIds, pageId],
);
const onCardDropRef = useRef<(args: {
draggedRowId: string;
sourceColumnKey: string;
targetColumnKey: string;
targetRowId: string | null;
edge: Edge | null;
}) => void>(() => {});
useLayoutEffect(() => {
onCardDropRef.current = ({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge }) => {
if (!groupByPropertyId) return;
const targetColumnRows = columnRows.current.get(targetColumnKey) ?? [];
const result = resolveCardDrop({
draggedRowId,
targetRowId,
edge: edge === "left" || edge === "right" ? null : edge,
targetColumnKey,
sourceColumnKey,
targetColumnRows,
});
if (!result) return;
const sourceFilter = buildColumnFilter(viewFilter, groupByPropertyId, sourceColumnKey);
const destFilter = buildColumnFilter(viewFilter, groupByPropertyId, targetColumnKey);
moveCard.mutate({
pageId,
rowId: draggedRowId,
sourceColumnFilter: sourceFilter,
destColumnFilter: destFilter,
columnChanged: result.columnChanged,
groupByPropertyId,
sourceColumnKey,
destColumnKey: targetColumnKey,
destChoiceValue: result.destChoiceValue,
position: result.position,
viewFilter,
});
const el = cardRefs.current.get(draggedRowId)?.el;
if (el) triggerPostMoveFlash(el);
const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
liveRegion.announce(t("Moved card to {{column}}", { column: targetColumnName }));
};
});
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) =>
source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const draggedRowId = source.data.rowId as string;
const sourceColumnKey = source.data.columnKey as string;
const targetColumnKey = target.data.columnKey as string;
const isColumnBody = target.data.isColumnBody === true;
const targetRowId = isColumnBody ? null : (target.data.rowId as string);
const edge = isColumnBody ? null : extractClosestEdge(target.data);
onCardDropRef.current({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge });
},
});
}, [pageId]);
const onColumnDropRef = useRef<(args: {
sourceColumnKey: string;
targetColumnKey: string;
edge: Edge | null;
}) => void>(() => {});
useLayoutEffect(() => {
onColumnDropRef.current = ({ sourceColumnKey, targetColumnKey, edge }) => {
const fullOrder: string[] = view.config?.choiceOrder?.length
? view.config.choiceOrder
: columns.map((c) => c.key);
const startIndex = fullOrder.indexOf(sourceColumnKey);
const indexOfTarget = fullOrder.indexOf(targetColumnKey);
if (startIndex === -1 || indexOfTarget === -1) {
const visibleKeys = columns.map((c) => c.key);
const visStart = visibleKeys.indexOf(sourceColumnKey);
const visTarget = visibleKeys.indexOf(targetColumnKey);
if (visStart === -1 || visTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex: visStart,
indexOfTarget: visTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === visStart) return;
const reorderedVisible = reorder({ list: visibleKeys, startIndex: visStart, finishIndex });
updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: [...reorderedVisible, ...(view.config?.hiddenChoiceIds ?? [])] } });
} else {
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "horizontal",
});
if (finishIndex === startIndex) return;
const newChoiceOrder = reorder({ list: fullOrder, startIndex, finishIndex });
updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: newChoiceOrder } });
}
const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
liveRegion.announce(t("Moved column to {{column}}", { column: targetColumnName }));
};
});
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) =>
source.data?.type === KANBAN_COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
onDrop: ({ location, source }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const sourceColumnKey = source.data.columnKey as string;
const targetColumnKey = target.data.columnKey as string;
const edge = extractClosestEdge(target.data);
onColumnDropRef.current({ sourceColumnKey, targetColumnKey, edge });
},
});
}, [pageId]);
if (!hasValidGroupBy) {
return <KanbanEmptyState base={base} view={view} pageId={pageId} editable={editable} />;
}
return (
<div
ref={boardRef}
className={clsx(classes.board, embedded ? classes.boardEmbed : classes.boardFullPage)}
>
{columns.map((column) => (
<KanbanColumn
key={column.key}
base={base}
view={view}
pageId={pageId}
column={column}
viewFilter={viewFilter}
groupByPropertyId={groupByPropertyId!}
count={groupCounts ? (groupCounts[column.key] ?? 0) : undefined}
canEdit={editable}
onOpenRow={handleOpenRow}
onHide={hideColumn}
registerCardRef={registerCardRef}
registerColumnRows={registerColumnRows}
/>
))}
</div>
);
}
@@ -0,0 +1,339 @@
import { Text, Badge, Tooltip, Group } from "@mantine/core";
import { IconCheck, IconFileDescription } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { sanitizeUrl } from "@docmost/editor-ext";
import {
IBaseProperty,
SelectTypeOptions,
NumberTypeOptions,
DateTypeOptions,
isFormulaErrorCell,
} from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { useReferenceStore, useResolvePage } from "@/ee/base/reference/reference-store";
import {
formatNumber,
formatDateDisplay,
formatTimestamp,
formatLongTextPreview,
} from "@/ee/base/formatters/cell-formatters";
import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
import { FileValue } from "@/ee/base/components/cells/cell-file";
import cellClasses from "@/ee/base/styles/cells.module.css";
type CardFieldProps = {
property: IBaseProperty;
value: unknown;
pageId: string;
};
export function CardField({ property, value, pageId }: CardFieldProps) {
if (value === null || value === undefined || value === "") return null;
if (Array.isArray(value) && value.length === 0) return null;
switch (property.type) {
case "text":
return <TextField value={value} />;
case "longText":
return <LongTextField value={value} />;
case "number":
return <NumberField value={value} property={property} />;
case "select":
case "status":
return <SelectField value={value} property={property} />;
case "multiSelect":
return <MultiSelectField value={value} property={property} />;
case "date":
return <DateField value={value} property={property} />;
case "createdAt":
case "lastEditedAt":
return <TimestampField value={value} />;
case "person":
return <PersonField value={value} pageId={pageId} />;
case "lastEditedBy":
return <LastEditedByField value={value} pageId={pageId} />;
case "file":
return <FileField value={value} />;
case "page":
return <PageField value={value} basePageId={pageId} propertyPageId={property.pageId} />;
case "checkbox":
return <CheckboxField value={value} />;
case "url":
return <UrlField value={value} />;
case "email":
return <EmailField value={value} />;
case "formula":
return <FormulaField value={value} property={property} />;
default:
return (
<Text size="xs" lineClamp={1}>
{String(value)}
</Text>
);
}
}
function TextField({ value }: { value: unknown }) {
const text = typeof value === "string" ? value : String(value);
if (!text) return null;
return (
<Text size="sm" lineClamp={2}>
{text}
</Text>
);
}
function LongTextField({ value }: { value: unknown }) {
const preview = formatLongTextPreview(typeof value === "string" ? value : undefined);
if (!preview) return null;
return (
<Text size="xs" c="dimmed" lineClamp={2}>
{preview}
</Text>
);
}
function NumberField({ value, property }: { value: unknown; property: IBaseProperty }) {
const num = typeof value === "number" ? value : null;
if (num === null) return null;
const formatted = formatNumber(num, property.typeOptions as NumberTypeOptions | undefined);
if (!formatted) return null;
return <Text size="sm">{formatted}</Text>;
}
function SelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedId = typeof value === "string" ? value : null;
const choice = choices.find((c) => c.id === selectedId);
if (!choice) return null;
return (
<ChoiceBadge
name={choice.name}
style={{ ...choiceColor(choice.color), alignSelf: "flex-start" }}
/>
);
}
function MultiSelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
if (selectedChoices.length === 0) return null;
const chips = selectedChoices.map((choice) => (
<span key={choice.id} className={cellClasses.badge} style={choiceColor(choice.color)}>
{choice.name}
</span>
));
return (
<BadgeOverflowList
chips={chips}
measureKey={selectedChoices.map((c) => `${c.id}:${c.name}`).join("|")}
tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
/>
);
}
function DateField({ value, property }: { value: unknown; property: IBaseProperty }) {
const dateStr = typeof value === "string" ? value : null;
const formatted = formatDateDisplay(dateStr, property.typeOptions as DateTypeOptions | undefined);
if (!formatted) return null;
return (
<Text size="xs" c="dimmed">
{formatted}
</Text>
);
}
function TimestampField({ value }: { value: unknown }) {
const formatted = formatTimestamp(typeof value === "string" ? value : null);
if (!formatted) return null;
return (
<Text size="xs" c="dimmed">
{formatted}
</Text>
);
}
function PersonField({ value, pageId }: { value: unknown; pageId: string }) {
const store = useReferenceStore(pageId);
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
if (personIds.length === 0) return null;
return <PersonReadList personIds={personIds} users={store.users} />;
}
function LastEditedByField({ value, pageId }: { value: unknown; pageId: string }) {
const userId = typeof value === "string" ? value : null;
const store = useReferenceStore(pageId);
if (!userId) return null;
const user = store.users[userId] ?? null;
const name = user?.name ?? userId.substring(0, 8);
return (
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
<CustomAvatar avatarUrl={user?.avatarUrl ?? ""} name={name} size={20} radius="xl" />
<Tooltip label={name} withinPortal openDelay={400} disabled={!name}>
<Text size="xs" truncate>
{name}
</Text>
</Tooltip>
</Group>
);
}
function FileField({ value }: { value: unknown }) {
const files = Array.isArray(value)
? (value as FileValue[]).filter((f) => f && typeof f === "object" && "id" in f && "fileName" in f)
: [];
if (files.length === 0) return null;
const maxVisible = 2;
const visible = files.slice(0, maxVisible);
const overflow = files.length - maxVisible;
return (
<div className={cellClasses.fileGroup}>
{visible.map((file) => (
<span key={file.id} className={cellClasses.fileBadge}>
{file.fileName}
</span>
))}
{overflow > 0 && <span className={cellClasses.overflowCount}>+{overflow}</span>}
</div>
);
}
function PageField({
value,
basePageId,
propertyPageId,
}: {
value: unknown;
basePageId: string;
propertyPageId: string;
}) {
const { t } = useTranslation();
const pageId = typeof value === "string" && value.length > 0 ? value : null;
const resolvedPage = useResolvePage(propertyPageId, pageId);
if (!pageId) return null;
if (resolvedPage === undefined) return null;
if (resolvedPage === null) {
return (
<span className={cellClasses.pageMissing}>
<IconFileDescription size={14} />
<span>Page not found</span>
</span>
);
}
const title = getPageTitle(resolvedPage.title, undefined, t);
const spaceSlug = resolvedPage.space?.slug ?? "";
const url = buildPageUrl(spaceSlug, resolvedPage.slugId, title);
return (
<Tooltip label={title} withinPortal openDelay={400} disabled={!title}>
<Link
to={url}
className={cellClasses.pagePill}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{resolvedPage.icon ? (
<span className={cellClasses.pagePillIcon}>{resolvedPage.icon}</span>
) : (
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
)}
<span className={cellClasses.pagePillText}>{title}</span>
</Link>
</Tooltip>
);
}
function CheckboxField({ value }: { value: unknown }) {
if (value !== true) return null;
return <IconCheck size={14} />;
}
function UrlField({ value }: { value: unknown }) {
const displayValue = typeof value === "string" ? value : "";
if (!displayValue) return null;
const safeHref = sanitizeUrl(displayValue);
if (!safeHref) {
return (
<Text size="xs" lineClamp={1}>
{displayValue}
</Text>
);
}
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.urlLink}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{ fontSize: "var(--mantine-font-size-xs)" }}
>
{displayValue}
</a>
</Tooltip>
);
}
function EmailField({ value }: { value: unknown }) {
const displayValue = typeof value === "string" ? value : "";
if (!displayValue) return null;
return (
<Tooltip label={displayValue} multiline withinPortal openDelay={400} maw={420}>
<a
className={cellClasses.emailLink}
href={`mailto:${displayValue}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: "var(--mantine-font-size-xs)" }}
>
{displayValue}
</a>
</Tooltip>
);
}
function FormulaField({ value, property }: { value: unknown; property: IBaseProperty }) {
if (isFormulaErrorCell(value)) {
return (
<Tooltip label={`${value.__err}: ${value.msg}`} withinPortal>
<Badge color="red" variant="light" size="sm">
#ERROR
</Badge>
</Tooltip>
);
}
const opts = (property.typeOptions ?? {}) as { resultType?: string };
const resultType = opts.resultType ?? "null";
if (resultType === "number") {
return <NumberField value={value} property={property} />;
}
if (resultType === "boolean") {
return <CheckboxField value={value} />;
}
if (resultType === "date") {
return <DateField value={value} property={property} />;
}
const text = typeof value === "string" ? value : value != null ? String(value) : null;
if (!text) return null;
return (
<Text size="sm" lineClamp={2}>
{text}
</Text>
);
}
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
import { IconPlus } from "@tabler/icons-react";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanAddCardButtonProps = {
onAddCard: () => void;
};
export function KanbanAddCardButton({ onAddCard }: KanbanAddCardButtonProps) {
const { t } = useTranslation();
return (
<div
className={classes.addCard}
role="button"
tabIndex={0}
onClick={onAddCard}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAddCard();
}
}}
>
<IconPlus size={16} />
{t("New row")}
</div>
);
}
@@ -0,0 +1,251 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Popover, Switch, Stack, Text, Group, UnstyledButton, ScrollArea } from "@mantine/core";
import { IconGripVertical, type IconLetterT } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
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 { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
import cellClasses from "@/ee/base/styles/cells.module.css";
import propClasses from "@/ee/base/styles/property.module.css";
const DRAG_TYPE = "base-card-property";
type KanbanCardPropertiesProps = {
opened: boolean;
onClose: () => void;
base: IBase;
view: IBaseView;
pageId: string;
children: React.ReactNode;
};
export function KanbanCardProperties({
opened,
onClose,
base,
view,
pageId,
children,
}: KanbanCardPropertiesProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const nonPrimaryProperties = base.properties.filter((p) => !p.isPrimary);
const visibleIds = view.config?.visiblePropertyIds ?? [];
const savedOrder = view.config?.propertyOrder ?? [];
const orderedProperties = [
...savedOrder
.map((id) => nonPrimaryProperties.find((p) => p.id === id))
.filter((p): p is IBaseProperty => p !== undefined),
...nonPrimaryProperties.filter((p) => !savedOrder.includes(p.id)),
];
const primaryProperty = base.properties.find((p) => p.isPrimary);
const PrimaryIcon = primaryProperty
? propertyTypes.find((pt) => pt.type === primaryProperty.type)?.icon
: undefined;
const handleToggle = useCallback(
(propertyId: string, checked: boolean) => {
const next = checked
? [...visibleIds, propertyId]
: visibleIds.filter((id) => id !== propertyId);
updateView.mutate({ viewId: view.id, pageId, config: { visiblePropertyIds: next } });
},
[updateView, view.id, visibleIds, pageId],
);
const handleReorder = useCallback(
(activeId: string, targetId: string, edge: Edge) => {
const startIndex = orderedProperties.findIndex((p) => p.id === activeId);
const indexOfTarget = orderedProperties.findIndex((p) => p.id === targetId);
if (startIndex === -1 || indexOfTarget === -1) return;
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget: edge,
axis: "vertical",
});
if (finishIndex === startIndex) return;
const reordered = reorder({ list: orderedProperties, startIndex, finishIndex });
updateView.mutate({
viewId: view.id,
pageId,
config: { propertyOrder: reordered.map((p) => p.id) },
});
},
[orderedProperties, updateView, view.id, pageId],
);
return (
<Popover
opened={opened}
onChange={(o) => {
if (!o) onClose();
}}
onClose={onClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
closeOnEscape
closeOnClickOutside
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap={4}>
<Group justify="space-between" px={4} py={2}>
<Text size="xs" fw={600} c="dimmed">
{t("Card properties")}
</Text>
</Group>
<ScrollArea.Autosize mah="min(60vh, 420px)" scrollbarSize={6} offsetScrollbars>
<Stack gap={0}>
{primaryProperty && (
<div className={cellClasses.menuItem} style={{ paddingLeft: 4, cursor: "default" }}>
<div className={propClasses.dragHandle} style={{ visibility: "hidden" }}>
<IconGripVertical size={14} />
</div>
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{PrimaryIcon && <PrimaryIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{primaryProperty.name}
</Text>
</Group>
<Switch
size="xs"
checked
disabled
onChange={() => {}}
styles={{ track: { cursor: "default" } }}
/>
</div>
)}
{orderedProperties.map((p) => {
const isVisible = visibleIds.includes(p.id);
const typeConfig = propertyTypes.find((pt) => pt.type === p.type);
const TypeIcon = typeConfig?.icon;
return (
<SortablePropertyRow
key={p.id}
property={p}
isVisible={isVisible}
TypeIcon={TypeIcon}
onToggle={handleToggle}
onReorder={handleReorder}
/>
);
})}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
type SortablePropertyRowProps = {
property: IBaseProperty;
isVisible: boolean;
TypeIcon: typeof IconLetterT | undefined;
onToggle: (propertyId: string, checked: boolean) => void;
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
};
function SortablePropertyRow({
property,
isVisible,
TypeIcon,
onToggle,
onReorder,
}: SortablePropertyRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const onReorderRef = useRef(onReorder);
useLayoutEffect(() => {
onReorderRef.current = onReorder;
});
useEffect(() => {
const row = rowRef.current;
const handle = handleRef.current;
if (!row || !handle) return;
return combine(
draggable({
element: row,
dragHandle: handle,
getInitialData: () => ({ type: DRAG_TYPE, propertyId: property.id }),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
}),
dropTargetForElements({
element: row,
canDrop: ({ source }) =>
source.data.type === DRAG_TYPE && source.data.propertyId !== property.id,
getData: ({ input, element }) =>
attachClosestEdge(
{ propertyId: property.id },
{ input, element, allowedEdges: ["top", "bottom"] },
),
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
onDragLeave: () => setClosestEdge(null),
onDrop: ({ source, self }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
if (!edge) return;
onReorderRef.current(source.data.propertyId as string, property.id, edge);
},
}),
);
}, [property.id]);
return (
<div
ref={rowRef}
style={{ position: "relative", opacity: isDragging ? 0.4 : 1 }}
>
<UnstyledButton
className={cellClasses.menuItem}
onClick={() => onToggle(property.id, !isVisible)}
style={{ paddingLeft: 4 }}
>
<div ref={handleRef} className={propClasses.dragHandle} onClick={(e) => e.stopPropagation()}>
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
</div>
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
<Text size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{property.name}
</Text>
</Group>
<Switch
size="xs"
checked={isVisible}
onChange={() => {}}
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: "pointer" } }}
/>
</UnstyledButton>
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -0,0 +1,85 @@
import { forwardRef, useCallback, useRef } from "react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { IBase, IBaseRow, IBaseView } from "@/ee/base/types/base.types";
import { CardField } from "@/ee/base/components/kanban/card-field/card-field";
import { useKanbanCardDnd } from "@/ee/base/hooks/use-kanban-card-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanCardProps = {
base: IBase;
view: IBaseView;
row: IBaseRow;
columnKey: string;
onOpen: (rowId: string) => void;
};
export const KanbanCard = forwardRef<HTMLDivElement, KanbanCardProps>(
function KanbanCard({ base, view, row, columnKey, onOpen }, ref) {
const { t } = useTranslation();
const primary = base.properties.find((p) => p.isPrimary);
const title = primary ? (row.cells[primary.id] as string | undefined) : undefined;
const visibleIds = view.config?.visiblePropertyIds ?? [];
const propertyOrder = view.config?.propertyOrder;
const cardProps = base.properties.filter(
(p) => visibleIds.includes(p.id) && !p.isPrimary,
);
if (propertyOrder) {
cardProps.sort(
(a, b) => propertyOrder.indexOf(a.id) - propertyOrder.indexOf(b.id),
);
}
const cardRef = useRef<HTMLDivElement>(null);
const setCardEl = useCallback(
(node: HTMLDivElement | null) => {
cardRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const { closestEdge, isDragging } = useKanbanCardDnd({
cardRef,
rowId: row.id,
columnKey,
pageId: base.id,
});
return (
<div
ref={setCardEl}
className={clsx(classes.card, isDragging && classes.cardDragging)}
role="button"
tabIndex={0}
onClick={() => onOpen(row.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen(row.id);
}
}}
>
{closestEdge === "top" && <BaseDropEdgeIndicator edge="top" />}
<div className={clsx(classes.cardTitle, !title && classes.cardUntitled)}>
{title || t("Untitled")}
</div>
{cardProps.map((property) => (
<CardField
key={property.id}
property={property}
value={row.cells[property.id]}
pageId={base.id}
/>
))}
{closestEdge === "bottom" && <BaseDropEdgeIndicator edge="bottom" />}
</div>
);
},
);
@@ -0,0 +1,76 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Menu, Text } from "@mantine/core";
import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
import clsx from "clsx";
import { KanbanColumn } from "@/ee/base/types/base.types";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnHeaderProps = {
column: KanbanColumn;
pageId: string;
count?: number;
canEdit: boolean;
onHide: () => void;
onAddCard: () => void;
};
export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
const { t } = useTranslation();
const dotColor = column.color
? choiceColor(column.color).color as string
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
const headerRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const { closestEdge, isDragging } = useKanbanColumnDnd({
headerRef,
handleRef,
columnKey: column.key,
pageId,
});
return (
<div ref={headerRef} className={clsx(classes.columnHeader, isDragging && classes.columnHeaderDragging)}>
{canEdit && (
<div ref={handleRef} className={classes.columnDragHandle} aria-hidden>
<IconGripVertical size={14} />
</div>
)}
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background: dotColor,
}}
/>
<Text fw={600} size="sm" flex={1} truncate>
{column.isNoValue ? t("No value") : column.name}
</Text>
{count !== undefined && <Text className={classes.count}>{count}</Text>}
{canEdit && (
<>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" size="sm" aria-label={t("Column options")}>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onHide}>{t("Hide group")}</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon variant="subtle" size="sm" aria-label={t("Add card")} onClick={onAddCard}>
<IconPlus size={14} />
</ActionIcon>
</>
)}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
</div>
);
}
@@ -0,0 +1,160 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { type IBase, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
import { useBaseRowsQuery } from "@/ee/base/queries/base-row-query";
import { useKanbanCreateCardMutation } from "@/ee/base/queries/base-row-query";
import { KanbanColumnHeader } from "@/ee/base/components/kanban/kanban-column-header";
import { KanbanAddCardButton } from "@/ee/base/components/kanban/kanban-add-card-button";
import { KanbanCard } from "@/ee/base/components/kanban/kanban-card";
import classes from "@/ee/base/styles/kanban.module.css";
type KanbanColumnProps = {
base: IBase;
view: IBaseView;
pageId: string;
column: KanbanColumnType;
viewFilter: FilterGroup | undefined;
groupByPropertyId: string;
count?: number;
canEdit: boolean;
onOpenRow: (rowId: string) => void;
onHide: (columnKey: string) => void;
registerCardRef: (rowId: string, columnKey: string, el: HTMLDivElement | null) => void;
registerColumnRows: (columnKey: string, rows: IBaseRow[]) => void;
};
export function KanbanColumn({
base,
view,
pageId,
column,
viewFilter,
groupByPropertyId,
count,
canEdit,
onOpenRow,
onHide,
registerCardRef,
registerColumnRows,
}: KanbanColumnProps) {
const filter = useMemo(
() => buildColumnFilter(viewFilter, groupByPropertyId, column.key),
[viewFilter, groupByPropertyId, column.key],
);
const rowsQuery = useBaseRowsQuery(pageId, filter, undefined, undefined);
const createCard = useKanbanCreateCardMutation();
const rows = useMemo(() => {
const pages = rowsQuery.data?.pages ?? [];
const seen = new Set<string>();
const flat: IBaseRow[] = [];
for (const page of pages) {
for (const row of page.items) {
if (!seen.has(row.id)) {
seen.add(row.id);
flat.push(row);
}
}
}
return flat.slice().sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsQuery.data]);
useEffect(() => {
registerColumnRows(column.key, rows);
}, [column.key, rows, registerColumnRows]);
const listRef = useRef<HTMLDivElement>(null);
useKanbanColumnAutoScroll(listRef, pageId);
const pendingScrollRef = useRef<"top" | "bottom" | null>(null);
useEffect(() => {
const placement = pendingScrollRef.current;
if (!placement) return;
pendingScrollRef.current = null;
const el = listRef.current;
if (!el) return;
el.scrollTop = placement === "top" ? 0 : el.scrollHeight;
}, [rows]);
useEffect(() => {
const listEl = listRef.current;
if (!listEl) return;
return dropTargetForElements({
element: listEl,
canDrop: ({ source }) =>
source.data.type === KANBAN_CARD_DRAG_TYPE && source.data.pageId === pageId,
getData: () => ({ columnKey: column.key, isColumnBody: true }),
});
}, [column.key, pageId]);
const onScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
const { scrollHeight, scrollTop, clientHeight } = el;
if (
scrollHeight - scrollTop - clientHeight < 200 &&
rowsQuery.hasNextPage &&
!rowsQuery.isFetchingNextPage
) {
rowsQuery.fetchNextPage();
}
}, [rowsQuery.hasNextPage, rowsQuery.isFetchingNextPage, rowsQuery.fetchNextPage]);
const addCard = useCallback(
(placement: "top" | "bottom") => {
let position: string | undefined;
try {
position =
placement === "top"
? generateJitteredKeyBetween(null, rows[0]?.position ?? null)
: generateJitteredKeyBetween(rows[rows.length - 1]?.position ?? null, null);
} catch {
position = undefined;
}
createCard.mutate(
{ pageId, destColumnFilter: filter, groupByPropertyId, columnKey: column.key, position, viewFilter },
{
onSuccess: (newRow) => {
pendingScrollRef.current = placement;
onOpenRow(newRow.id);
},
},
);
},
[createCard, pageId, filter, groupByPropertyId, column.key, viewFilter, onOpenRow, rows],
);
return (
<div className={classes.column} data-column-key={column.key}>
<KanbanColumnHeader
column={column}
pageId={pageId}
count={count}
canEdit={canEdit}
onHide={() => onHide(column.key)}
onAddCard={() => addCard("top")}
/>
<div className={classes.cardList} ref={listRef} onScroll={onScroll}>
{rows.map((row) => (
<KanbanCard
key={row.id}
base={base}
view={view}
row={row}
columnKey={column.key}
onOpen={onOpenRow}
ref={(el) => registerCardRef(row.id, column.key, el)}
/>
))}
{canEdit && <KanbanAddCardButton onAddCard={() => addCard("bottom")} />}
</div>
</div>
);
}
@@ -0,0 +1,99 @@
import { useCallback } from "react";
import { Stack, Text, Select, Button } from "@mantine/core";
import { v7 as uuid7 } from "uuid";
import { useTranslation } from "react-i18next";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
type KanbanEmptyStateProps = {
base: IBase;
view: IBaseView;
pageId: string;
editable: boolean;
};
export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptyStateProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const createProperty = useCreatePropertyMutation();
const groupableProperties = base.properties.filter(
(p) => p.type === "select" || p.type === "status",
);
const selectData = groupableProperties.map((p) => ({
value: p.id,
label: p.name,
}));
const handleSelect = useCallback(
(value: string | null) => {
if (!value) return;
updateView.mutate({ viewId: view.id, pageId, config: { groupByPropertyId: value } });
},
[updateView, view.id, pageId],
);
const handleCreateStatus = useCallback(() => {
const todoId = uuid7();
const inProgressId = uuid7();
const completeId = uuid7();
createProperty.mutate(
{
pageId,
name: t("Status"),
type: "status",
typeOptions: {
choices: [
{ id: todoId, name: t("Not started"), color: "gray", category: "todo" },
{ id: inProgressId, name: t("In progress"), color: "blue", category: "inProgress" },
{ id: completeId, name: t("Done"), color: "green", category: "complete" },
],
choiceOrder: [todoId, inProgressId, completeId],
},
},
{
onSuccess: (newProperty) => {
updateView.mutate({
viewId: view.id,
pageId,
config: { groupByPropertyId: newProperty.id },
});
},
},
);
}, [createProperty, updateView, view.id, pageId, t]);
if (!editable) {
return (
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
<Text fw={500}>{t("This board has no grouping property yet.")}</Text>
</Stack>
);
}
return (
<Stack align="center" justify="center" gap="md" style={{ flex: 1 }}>
<Text fw={500}>{t("Group this board by a select or status property.")}</Text>
{groupableProperties.length > 0 ? (
<Select
placeholder={t("Choose a property")}
data={selectData}
value={view.config?.groupByPropertyId ?? null}
onChange={handleSelect}
w={240}
/>
) : (
<Button
variant="light"
size="sm"
onClick={handleCreateStatus}
loading={createProperty.isPending}
>
{t("Create a status property")}
</Button>
)}
</Stack>
);
}
@@ -0,0 +1,115 @@
import { Popover, Select, Stack, Text, Switch, Group, UnstyledButton } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
import { choiceColor } from "@/ee/base/components/cells/choice-color";
import cellClasses from "@/ee/base/styles/cells.module.css";
type KanbanGroupByPickerProps = {
base: IBase;
view: IBaseView;
pageId: string;
children: React.ReactNode;
};
export function KanbanGroupByPicker({ base, view, pageId, children }: KanbanGroupByPickerProps) {
const { t } = useTranslation();
const updateView = useUpdateViewMutation();
const { allGroups, hasValidGroupBy } = useKanbanColumns(base, view);
const data = base.properties
.filter((p) => p.type === "select" || p.type === "status")
.map((p) => ({ value: p.id, label: p.name }));
const handleChange = (value: string | null) => {
updateView.mutate({
viewId: view.id,
pageId,
config: { groupByPropertyId: value ?? null },
});
};
const toggleGroup = (key: string, currentlyHidden: boolean) => {
const current = view.config?.hiddenChoiceIds ?? [];
const next = currentlyHidden
? current.filter((k) => k !== key)
: [...current, key];
updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
};
return (
<Popover
position="bottom-end"
shadow="md"
width={300}
withinPortal
trapFocus
closeOnEscape
closeOnClickOutside
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown p="xs">
<Stack gap={8}>
<Text size="xs" fw={600} c="dimmed">
{t("Group by")}
</Text>
<Select
size="xs"
placeholder={t("Select a property")}
data={data}
value={view.config?.groupByPropertyId ?? null}
onChange={handleChange}
clearable
/>
{hasValidGroupBy && allGroups.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed">
{t("Groups")}
</Text>
<Stack gap={0}>
{allGroups.map((g) => {
const dotColor = g.color
? (choiceColor(g.color).color as string)
: "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
return (
<UnstyledButton
key={g.key}
className={cellClasses.menuItem}
onClick={() => toggleGroup(g.key, g.hidden)}
>
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
flexShrink: 0,
background: dotColor,
}}
/>
<Text
size="sm"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{g.isNoValue ? t("No value") : g.name}
</Text>
</Group>
<Switch
size="xs"
checked={!g.hidden}
onChange={() => {}}
onClick={(e) => e.stopPropagation()}
styles={{ track: { cursor: "pointer" } }}
/>
</UnstyledButton>
);
})}
</Stack>
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -30,7 +30,7 @@ import classes from "@/ee/base/styles/grid.module.css";
type CreatePropertyPopoverProps = { type CreatePropertyPopoverProps = {
pageId: string; pageId: string;
properties?: IBaseProperty[]; properties?: IBaseProperty[];
onPropertyCreated?: () => void; onPropertyCreated?: (property: IBaseProperty) => void;
/** Custom trigger; must return a ref-forwarding element for Popover.Target. /** Custom trigger; must return a ref-forwarding element for Popover.Target.
* Defaults to the grid's + column button. */ * Defaults to the grid's + column button. */
renderTarget?: (open: () => void) => React.ReactElement; renderTarget?: (open: () => void) => React.ReactElement;
@@ -145,8 +145,8 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
: undefined, : undefined,
}, },
{ {
onSuccess: () => { onSuccess: (created) => {
onPropertyCreated?.(); onPropertyCreated?.(created);
}, },
}, },
); );
@@ -293,7 +293,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r
astVersion: 1, astVersion: 1,
} as TypeOptions, } as TypeOptions,
}, },
{ onSuccess: () => onPropertyCreated?.() }, { onSuccess: (created) => onPropertyCreated?.(created) },
); );
handleClose(); handleClose();
}} }}
@@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useEffect, useRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { Popover } from "@mantine/core"; import { Popover } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react"; import { IconChevronDown } from "@tabler/icons-react";
@@ -17,6 +17,8 @@ type PropertyRowProps = {
onMenuOpenChange: (opened: boolean) => void; onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void; onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void; onUpdate: (propertyId: string, value: unknown) => void;
autoFocusValue?: boolean;
onAutoFocused?: () => void;
}; };
export function PropertyRow({ export function PropertyRow({
@@ -27,8 +29,23 @@ export function PropertyRow({
onMenuOpenChange, onMenuOpenChange,
onMenuDirtyChange, onMenuDirtyChange,
onUpdate, onUpdate,
autoFocusValue,
onAutoFocused,
}: PropertyRowProps) { }: PropertyRowProps) {
const canEdit = useBaseEditable(); const canEdit = useBaseEditable();
const rowRef = useRef<HTMLDivElement>(null);
const focusedRef = useRef(false);
useEffect(() => {
if (!autoFocusValue || focusedRef.current) return;
focusedRef.current = true;
const el = rowRef.current;
if (el) {
el.scrollIntoView({ block: "nearest" });
el.querySelector<HTMLElement>("input, textarea")?.focus();
}
onAutoFocused?.();
}, [autoFocusValue, onAutoFocused]);
const handleLabelClick = useCallback(() => { const handleLabelClick = useCallback(() => {
onMenuOpenChange(!menuOpened); onMenuOpenChange(!menuOpened);
@@ -48,7 +65,7 @@ export function PropertyRow({
); );
return ( return (
<div className={classes.propertyRow}> <div className={classes.propertyRow} ref={rowRef}>
{canEdit ? ( {canEdit ? (
<Popover <Popover
opened={menuOpened} opened={menuOpened}
@@ -80,6 +80,8 @@ export function RowDetailModal({
// The shared closeRequest atom asks an open dirty PropertyMenuContent to // The shared closeRequest atom asks an open dirty PropertyMenuContent to
// run its discard-confirm flow instead of being torn down mid-edit. // run its discard-confirm flow instead of being torn down mid-edit.
const [openMenuId, setOpenMenuId] = useState<string | null>(null); const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [newPropertyId, setNewPropertyId] = useState<string | null>(null);
const clearNewProperty = useCallback(() => setNewPropertyId(null), []);
const menuDirtyRef = useRef(false); const menuDirtyRef = useRef(false);
const [closeRequest, setCloseRequest] = useAtom( const [closeRequest, setCloseRequest] = useAtom(
propertyMenuCloseRequestAtomFamily(base.id), propertyMenuCloseRequestAtomFamily(base.id),
@@ -310,6 +312,8 @@ export function RowDetailModal({
property={property} property={property}
row={row} row={row}
pageId={base.id} pageId={base.id}
autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id} menuOpened={openMenuId === property.id}
onMenuOpenChange={(nextOpened) => onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened) handleMenuOpenChange(property.id, nextOpened)
@@ -329,6 +333,7 @@ export function RowDetailModal({
<CreatePropertyPopover <CreatePropertyPopover
pageId={base.id} pageId={base.id}
properties={base.properties} properties={base.properties}
onPropertyCreated={(p) => setNewPropertyId(p.id)}
renderTarget={(open) => ( renderTarget={(open) => (
<button <button
type="button" type="button"
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types"; import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts"; import { timeAgo } from "@/lib/time.ts";
@@ -22,18 +22,27 @@ export function RowDetailTitle({
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "") ? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: ""; : "";
const [value, setValue] = useState(initial); const [value, setValue] = useState(initial);
const inputRef = useRef<HTMLInputElement>(null);
const didAutofocusRef = useRef(false);
// Re-sync when the row changes underneath us (navigation or remote edit). // Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => { useEffect(() => {
setValue(initial); setValue(initial);
}, [initial]); }, [initial]);
useEffect(() => {
if (didAutofocusRef.current || !canEdit || initial) return;
didAutofocusRef.current = true;
inputRef.current?.focus();
}, [canEdit, initial]);
const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : ""; const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
return ( return (
<header className={classes.header}> <header className={classes.header}>
{canEdit ? ( {canEdit ? (
<input <input
ref={inputRef}
type="text" type="text"
className={classes.titleInput} className={classes.titleInput}
placeholder={t("Untitled")} placeholder={t("Untitled")}
@@ -0,0 +1,140 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useAtom } from "jotai";
import { Menu, ActionIcon, Tooltip } from "@mantine/core";
import { IconPlus, IconTable, IconLayoutKanban, IconArrowLeft } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IBase } from "@/ee/base/types/base.types";
import { useCreateViewMutation } from "@/ee/base/queries/base-view-query";
import { activeViewIdAtomFamily } from "@/ee/base/atoms/base-atoms";
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
type Panel = "types" | "groupBy";
type ViewCreateMenuProps = {
base: IBase;
pageId: string;
};
export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [panel, setPanel] = useState<Panel>("types");
const dropdownRef = useRef<HTMLDivElement>(null);
const createViewMutation = useCreateViewMutation();
const [, setActiveViewId] = useAtom(
activeViewIdAtomFamily(pageId),
) as unknown as [string | null, (val: string | null) => void];
const groupable = base.properties.filter(
(p) => p.type === "select" || p.type === "status",
);
const close = useCallback(() => {
setOpened(false);
setPanel("types");
}, []);
const submitView = useCallback(
(input: { name: string; type: "table" | "kanban"; config?: Record<string, unknown> }) => {
createViewMutation.mutate(
{ pageId, ...input },
{ onSuccess: (created) => setActiveViewId(created.id) },
);
close();
},
[pageId, createViewMutation, setActiveViewId, close],
);
const handleCreateTable = useCallback(() => {
submitView({ name: t("Table"), type: "table" });
}, [submitView, t]);
const handleBoardClick = useCallback(() => {
if (groupable.length <= 1) {
const config =
groupable.length === 1
? { groupByPropertyId: groupable[0].id }
: undefined;
submitView({ name: t("Kanban"), type: "kanban", config });
} else {
setPanel("groupBy");
}
}, [groupable, submitView, t]);
const handleGroupByPick = useCallback(
(propertyId: string) => {
submitView({
name: t("Kanban"),
type: "kanban",
config: { groupByPropertyId: propertyId },
});
},
[submitView, t],
);
useEffect(() => {
const raf = requestAnimationFrame(() => {
dropdownRef.current
?.querySelector<HTMLElement>("[data-menu-item]:not([data-disabled])")
?.focus();
});
return () => cancelAnimationFrame(raf);
}, [panel]);
return (
<Menu
opened={opened}
onChange={(o) => {
setOpened(o);
if (!o) setPanel("types");
}}
position="bottom-start"
shadow="md"
width={200}
withinPortal
closeOnItemClick={false}
>
<Menu.Target>
<Tooltip label={t("Add view")}>
<ActionIcon variant="subtle" size="sm" color="gray" aria-label={t("Add view")}>
<IconPlus size={14} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown ref={dropdownRef}>
{panel === "types" && (
<>
<Menu.Item leftSection={<IconTable size={14} />} onClick={handleCreateTable}>
{t("Table")}
</Menu.Item>
<Menu.Item leftSection={<IconLayoutKanban size={14} />} onClick={handleBoardClick}>
{t("Kanban")}
</Menu.Item>
</>
)}
{panel === "groupBy" && (
<>
<Menu.Item leftSection={<IconArrowLeft size={14} />} onClick={() => setPanel("types")}>
{t("Group by")}
</Menu.Item>
<Menu.Divider />
{groupable.map((p) => {
const Icon = getDescriptor(p.type)?.icon;
return (
<Menu.Item
key={p.id}
leftSection={Icon ? <Icon size={14} /> : undefined}
onClick={() => handleGroupByPick(p.id)}
>
{p.name}
</Menu.Item>
);
})}
</>
)}
</Menu.Dropdown>
</Menu>
);
}
@@ -3,8 +3,10 @@ import {
IBase, IBase,
IBaseRow, IBaseRow,
IBaseView, IBaseView,
FilterGroup,
} from "@/ee/base/types/base.types"; } from "@/ee/base/types/base.types";
import { BaseTable } from "@/ee/base/components/base-table"; import { BaseTable } from "@/ee/base/components/base-table";
import { BaseKanban } from "@/ee/base/components/kanban/base-kanban";
type ViewRendererProps = { type ViewRendererProps = {
base: IBase; base: IBase;
@@ -13,6 +15,7 @@ type ViewRendererProps = {
table: Table<IBaseRow>; table: Table<IBaseRow>;
pageId: string; pageId: string;
embedded?: boolean; embedded?: boolean;
editable: boolean;
isFiltered: boolean; isFiltered: boolean;
hasNextPage: boolean; hasNextPage: boolean;
isFetchingNextPage: boolean; isFetchingNextPage: boolean;
@@ -29,15 +32,28 @@ type ViewRendererProps = {
persistViewConfig: () => void; persistViewConfig: () => void;
scrollportRef: React.RefObject<HTMLDivElement>; scrollportRef: React.RefObject<HTMLDivElement>;
aboveBand?: React.ReactNode; aboveBand?: React.ReactNode;
kanbanFilter?: FilterGroup | undefined;
}; };
export function ViewRenderer(props: ViewRendererProps) { export function ViewRenderer(props: ViewRendererProps) {
const viewType = props.effectiveView?.type ?? "table"; const viewType = props.effectiveView?.type ?? "table";
if (viewType === "kanban") {
return (
<BaseKanban
base={props.base}
view={props.effectiveView!}
pageId={props.pageId}
embedded={props.embedded}
editable={props.editable}
viewFilter={props.kanbanFilter}
/>
);
}
if (viewType === "table") { if (viewType === "table") {
return <BaseTable {...props} />; return <BaseTable {...props} />;
} }
// Kanban not yet implemented; fall back to table to avoid blank page.
return <BaseTable {...props} />; return <BaseTable {...props} />;
} }
@@ -10,19 +10,17 @@ import {
Group, Group,
UnstyledButton, UnstyledButton,
Text, Text,
ActionIcon,
Tooltip,
TextInput, TextInput,
Popover, Popover,
Stack, Stack,
Divider, Divider,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconPlus,
IconPencil, IconPencil,
IconTrash, IconTrash,
IconTable, IconTable,
IconLink, IconLink,
IconLayoutKanban,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
@@ -36,7 +34,8 @@ import {
type Edge, type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { IBaseView } from "@/ee/base/types/base.types"; import { IBase, IBaseView } from "@/ee/base/types/base.types";
import { ViewCreateMenu } from "@/ee/base/components/views/view-create-menu";
import { import {
useUpdateViewMutation, useUpdateViewMutation,
useDeleteViewMutation, useDeleteViewMutation,
@@ -54,6 +53,8 @@ type ViewTabsProps = {
pageId: string; pageId: string;
onViewChange: (viewId: string) => void; onViewChange: (viewId: string) => void;
onAddView?: () => void; onAddView?: () => void;
base?: IBase;
canAddView?: boolean;
/** Standalone base-page link for a view, used by "Copy link to view". */ /** Standalone base-page link for a view, used by "Copy link to view". */
getViewShareUrl?: (viewId: string) => string | null; getViewShareUrl?: (viewId: string) => string | null;
}; };
@@ -64,6 +65,8 @@ export function ViewTabs({
pageId, pageId,
onViewChange, onViewChange,
onAddView, onAddView,
base,
canAddView,
getViewShareUrl, getViewShareUrl,
}: ViewTabsProps) { }: ViewTabsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -174,7 +177,6 @@ export function ViewTabs({
isEditing={view.id === editingViewId} isEditing={view.id === editingViewId}
editingName={editingName} editingName={editingName}
canDelete={orderedViews.length > 1} canDelete={orderedViews.length > 1}
multipleViews={orderedViews.length > 1}
reorderEnabled={editable && orderedViews.length > 1} reorderEnabled={editable && orderedViews.length > 1}
onReorder={handleReorder} onReorder={handleReorder}
onClick={() => onViewChange(view.id)} onClick={() => onViewChange(view.id)}
@@ -186,17 +188,8 @@ export function ViewTabs({
getViewShareUrl={getViewShareUrl} getViewShareUrl={getViewShareUrl}
/> />
))} ))}
{onAddView && ( {canAddView && base && (
<Tooltip label={t("Add view")}> <ViewCreateMenu base={base} pageId={pageId} />
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={onAddView}
>
<IconPlus size={14} />
</ActionIcon>
</Tooltip>
)} )}
</Group> </Group>
); );
@@ -208,7 +201,6 @@ function ViewTab({
isEditing, isEditing,
editingName, editingName,
canDelete, canDelete,
multipleViews,
reorderEnabled, reorderEnabled,
onReorder, onReorder,
onClick, onClick,
@@ -224,7 +216,6 @@ function ViewTab({
isEditing: boolean; isEditing: boolean;
editingName: string; editingName: string;
canDelete: boolean; canDelete: boolean;
multipleViews: boolean;
reorderEnabled: boolean; reorderEnabled: boolean;
onReorder: (sourceId: string, targetId: string, edge: Edge) => void; onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
onClick: () => void; onClick: () => void;
@@ -336,14 +327,17 @@ function ViewTab({
padding: "2px 10px", padding: "2px 10px",
borderRadius: "var(--mantine-radius-xl)", borderRadius: "var(--mantine-radius-xl)",
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 400,
backgroundColor: backgroundColor: isActive
isActive && multipleViews ? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))"
? "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))" : undefined,
: undefined,
}} }}
> >
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<IconTable size={14} opacity={isActive ? 1 : 0.5} /> {view.type === "kanban" ? (
<IconLayoutKanban size={14} opacity={isActive ? 1 : 0.5} />
) : (
<IconTable size={14} opacity={isActive ? 1 : 0.5} />
)}
<Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}> <Text size="sm" lh={1.2} c={isActive ? undefined : "dimmed"}>
{view.name} {view.name}
</Text> </Text>
@@ -0,0 +1,22 @@
import { formatNumber } from "@/ee/base/components/cells/cell-number";
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
export { formatNumber, formatDateDisplay };
export function formatTimestamp(value: string | null | undefined): string {
if (typeof value !== "string" || !value) return "";
const date = new Date(value);
if (isNaN(date.getTime())) return "";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function formatLongTextPreview(value: string | null | undefined): string {
if (typeof value !== "string") return "";
return value.replace(/\s+/g, " ").trim();
}
@@ -11,6 +11,7 @@ import {
import { selectedRowIdsAtomFamily } from "@/ee/base/atoms/base-atoms"; import { selectedRowIdsAtomFamily } from "@/ee/base/atoms/base-atoms";
import { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom"; import { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom";
import { IPagination } from "@/lib/types"; import { IPagination } from "@/lib/types";
import { invalidateKanbanColumns } from "@/ee/base/queries/base-row-query";
type BaseRowCreated = { type BaseRowCreated = {
operation: "base:row:created"; operation: "base:row:created";
@@ -161,45 +162,57 @@ export function useBaseSocket(pageId: string | undefined): void {
switch (event.operation) { switch (event.operation) {
case "base:row:created": { case "base:row:created": {
const e = event as BaseRowCreated; const e = event as BaseRowCreated;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>( const baseForCreate = queryClient.getQueryData<IBase>(["bases", pageId]);
{ queryKey: ["base-rows", pageId] }, const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
(old) => { if (hasKanbanForCreate) {
if (!old) return old; invalidateKanbanColumns(pageId);
const lastPageIndex = old.pages.length - 1; } else {
return { queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
...old, { queryKey: ["base-rows", pageId] },
pages: old.pages.map((page, index) => (old) => {
index === lastPageIndex if (!old) return old;
? { ...page, items: [...page.items, e.row] } const lastPageIndex = old.pages.length - 1;
: page, return {
), ...old,
}; pages: old.pages.map((page, index) =>
}, index === lastPageIndex
); ? { ...page, items: [...page.items, e.row] }
: page,
),
};
},
);
}
break; break;
} }
case "base:row:updated": { case "base:row:updated": {
const e = event as BaseRowUpdated; const e = event as BaseRowUpdated;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>( const baseForUpdate = queryClient.getQueryData<IBase>(["bases", pageId]);
{ queryKey: ["base-rows", pageId] }, const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban");
(old) => if (hasKanbanForUpdate) {
!old invalidateKanbanColumns(pageId);
? old } else {
: { queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
...old, { queryKey: ["base-rows", pageId] },
pages: old.pages.map((page) => ({ (old) =>
...page, !old
items: page.items.map((row) => ? old
row.id === e.rowId : {
? { ...old,
...row, pages: old.pages.map((page) => ({
cells: { ...row.cells, ...e.updatedCells }, ...page,
} items: page.items.map((row) =>
: row, row.id === e.rowId
), ? {
})), ...row,
}, cells: { ...row.cells, ...e.updatedCells },
); }
: row,
),
})),
},
);
}
break; break;
} }
case "base:row:deleted": { case "base:row:deleted": {
@@ -258,23 +271,29 @@ export function useBaseSocket(pageId: string | undefined): void {
} }
case "base:row:reordered": { case "base:row:reordered": {
const e = event as BaseRowReordered; const e = event as BaseRowReordered;
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>( const baseForReorder = queryClient.getQueryData<IBase>(["bases", pageId]);
{ queryKey: ["base-rows", pageId] }, const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban");
(old) => if (hasKanbanForReorder) {
!old invalidateKanbanColumns(pageId);
? old } else {
: { queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
...old, { queryKey: ["base-rows", pageId] },
pages: old.pages.map((page) => ({ (old) =>
...page, !old
items: page.items.map((row) => ? old
row.id === e.rowId : {
? { ...row, position: e.position } ...old,
: row, pages: old.pages.map((page) => ({
), ...page,
})), items: page.items.map((row) =>
}, row.id === e.rowId
); ? { ...row, position: e.position }
: row,
),
})),
},
);
}
break; break;
} }
case "base:rows:updated": { case "base:rows:updated": {
@@ -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]);
}
@@ -58,8 +58,8 @@ export function useConvertPageToBaseMutation() {
const [, setTreeData] = useAtom(treeDataAtom); const [, setTreeData] = useAtom(treeDataAtom);
const [socket] = useAtom(socketAtom); const [socket] = useAtom(socketAtom);
return useMutation<IBase, Error, { pageId: string }>({ return useMutation<IBase, Error, { pageId: string; template?: "kanban" }>({
mutationFn: ({ pageId }) => convertPageToBase(pageId), mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
onSuccess: (base) => { onSuccess: (base) => {
queryClient.invalidateQueries({ queryKey: ["pages"] }); queryClient.invalidateQueries({ queryKey: ["pages"] });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -14,9 +14,11 @@ import {
listRows, listRows,
reorderRow, reorderRow,
countRows, countRows,
groupCountRows,
IBaseRowsPage, IBaseRowsPage,
} from "@/ee/base/services/base-service"; } from "@/ee/base/services/base-service";
import { import {
IBase,
IBaseRow, IBaseRow,
CreateRowInput, CreateRowInput,
UpdateRowInput, UpdateRowInput,
@@ -27,7 +29,9 @@ import {
SearchSpec, SearchSpec,
ViewSortConfig, ViewSortConfig,
CountRowsResult, CountRowsResult,
GroupCountsResult,
RowReferences, RowReferences,
NO_VALUE_CHOICE_ID,
} from "@/ee/base/types/base.types"; } from "@/ee/base/types/base.types";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { queryClient } from "@/main"; import { queryClient } from "@/main";
@@ -42,7 +46,7 @@ type RowCacheContext = {
// An empty group filter is the draft-layer's "no predicates" marker (see use-view-draft.ts). // An empty group filter is the draft-layer's "no predicates" marker (see use-view-draft.ts).
// Strip it at the query boundary to keep request payloads clean and cache keys stable. // Strip it at the query boundary to keep request payloads clean and cache keys stable.
function normalizeFilter(filter: FilterNode | undefined): FilterNode | undefined { export function normalizeFilter(filter: FilterNode | undefined): FilterNode | undefined {
if (!filter) return undefined; if (!filter) return undefined;
if ('children' in filter && filter.children.length === 0) return undefined; if ('children' in filter && filter.children.length === 0) return undefined;
return filter; return filter;
@@ -55,6 +59,61 @@ function newRequestId(): string {
return id; return id;
} }
export function baseRowsQueryKey(
pageId: string | undefined,
filter: FilterNode | undefined,
sorts: ViewSortConfig[] | undefined,
search: SearchSpec | undefined,
) {
return [
"base-rows",
pageId,
normalizeFilter(filter),
sorts?.length ? sorts : undefined,
search?.query ? search : undefined,
] as const;
}
export function baseRowsCountKey(
pageId: string | undefined,
filter: FilterNode | undefined,
search: SearchSpec | undefined,
) {
return [
"base-rows-count",
pageId,
normalizeFilter(filter),
search?.query ? search : undefined,
] as const;
}
export function baseRowGroupCountsKey(
pageId: string | undefined,
groupByPropertyId: string | undefined,
filter: FilterNode | undefined,
search: SearchSpec | undefined,
) {
return [
"base-row-group-counts",
pageId,
groupByPropertyId,
normalizeFilter(filter),
search?.query ? search : undefined,
] as const;
}
export function findRowInInfinite(
data: InfiniteData<IBaseRowsPage> | undefined,
rowId: string,
): IBaseRow | undefined {
if (!data) return undefined;
for (const page of data.pages) {
const row = page.items.find((r) => r.id === rowId);
if (row) return row;
}
return undefined;
}
export function useBaseRowsQuery( export function useBaseRowsQuery(
pageId: string | undefined, pageId: string | undefined,
filter?: FilterNode, filter?: FilterNode,
@@ -66,7 +125,7 @@ export function useBaseRowsQuery(
const activeSearch = search?.query ? search : undefined; const activeSearch = search?.query ? search : undefined;
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["base-rows", pageId, activeFilter, activeSorts, activeSearch], queryKey: baseRowsQueryKey(pageId, filter, sorts, search),
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
listRows(pageId!, { listRows(pageId!, {
cursor: pageParam, cursor: pageParam,
@@ -122,6 +181,10 @@ export function useCreateRowMutation() {
}; };
}, },
); );
const base = queryClient.getQueryData<IBase>(["bases", newRow.pageId]);
if ((base?.views ?? []).some((v) => v.type === "kanban")) {
invalidateKanbanColumns(newRow.pageId);
}
}, },
onError: () => { onError: () => {
notifications.show({ notifications.show({
@@ -209,7 +272,7 @@ export function useUpdateRowMutation() {
color: "red", color: "red",
}); });
}, },
onSuccess: (updatedRow) => { onSuccess: (updatedRow, variables) => {
queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>( queryClient.setQueriesData<InfiniteData<IBaseRowsPage>>(
{ queryKey: ["base-rows", updatedRow.pageId] }, { queryKey: ["base-rows", updatedRow.pageId] },
(old) => { (old) => {
@@ -234,6 +297,18 @@ export function useUpdateRowMutation() {
? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } } ? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } }
: old, : old,
); );
const base = queryClient.getQueryData<IBase>(["bases", variables.pageId]);
const kanbanGroupByIds = new Set(
(base?.views ?? [])
.filter((v) => v.type === "kanban")
.map((v) => v.config?.groupByPropertyId)
.filter(Boolean) as string[],
);
const changedPropertyIds = Object.keys(variables.cells ?? {});
if (changedPropertyIds.some((id) => kanbanGroupByIds.has(id))) {
invalidateKanbanColumns(variables.pageId);
}
}, },
}); });
} }
@@ -336,7 +411,7 @@ export function useBaseRowsCountQuery(
const activeSearch = search?.query ? search : undefined; const activeSearch = search?.query ? search : undefined;
return useQuery<CountRowsResult>({ return useQuery<CountRowsResult>({
queryKey: ["base-rows-count", pageId, activeFilter, activeSearch], queryKey: baseRowsCountKey(pageId, filter, search),
queryFn: () => queryFn: () =>
countRows({ countRows({
pageId: pageId!, pageId: pageId!,
@@ -348,6 +423,57 @@ export function useBaseRowsCountQuery(
}); });
} }
function toGroupCountMap(result: GroupCountsResult): Record<string, number> {
const map: Record<string, number> = {};
for (const { value, count } of result.counts) {
const key = value == null || value === "" ? NO_VALUE_CHOICE_ID : value;
map[key] = (map[key] ?? 0) + count;
}
return map;
}
export function useBaseRowGroupCountsQuery(
pageId: string | undefined,
groupByPropertyId: string | undefined,
filter?: FilterNode,
search?: SearchSpec,
) {
const activeFilter = normalizeFilter(filter);
const activeSearch = search?.query ? search : undefined;
return useQuery<Record<string, number>>({
queryKey: baseRowGroupCountsKey(pageId, groupByPropertyId, filter, search),
queryFn: async () =>
toGroupCountMap(
await groupCountRows({
pageId: pageId!,
groupByPropertyId: groupByPropertyId!,
filter: activeFilter,
search: activeSearch,
}),
),
enabled: !!pageId && !!groupByPropertyId,
staleTime: 30 * 1000,
});
}
function adjustGroupCount(
pageId: string,
groupByPropertyId: string,
filter: FilterNode | undefined,
search: SearchSpec | undefined,
columnKey: string,
delta: number,
) {
queryClient.setQueryData<Record<string, number>>(
baseRowGroupCountsKey(pageId, groupByPropertyId, filter, search),
(old) =>
old
? { ...old, [columnKey]: Math.max(0, (old[columnKey] ?? 0) + delta) }
: old,
);
}
export function useReorderRowMutation() { export function useReorderRowMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({ return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
@@ -394,3 +520,168 @@ export function useReorderRowMutation() {
}, },
}); });
} }
type KanbanMoveCardInput = {
pageId: string;
rowId: string;
sourceColumnFilter: FilterNode | undefined;
destColumnFilter: FilterNode | undefined;
columnChanged: boolean;
groupByPropertyId: string;
sourceColumnKey: string;
destColumnKey: string;
destChoiceValue: string | null;
position: string;
viewFilter?: FilterNode;
search?: SearchSpec;
};
type KanbanMoveCardContext = {
snapshots: [readonly unknown[], unknown][];
};
export function useKanbanMoveCardMutation() {
const { t } = useTranslation();
return useMutation<IBaseRow, Error, KanbanMoveCardInput, KanbanMoveCardContext>({
mutationFn: ({ pageId, rowId, columnChanged, groupByPropertyId, destChoiceValue, position }) =>
updateRow({
pageId,
rowId,
cells: columnChanged ? { [groupByPropertyId]: destChoiceValue } : {},
position,
requestId: newRequestId(),
}),
onMutate: async (variables) => {
const { pageId, rowId, sourceColumnFilter, destColumnFilter, columnChanged, groupByPropertyId, sourceColumnKey, destColumnKey, destChoiceValue, position, viewFilter, search } = variables;
await queryClient.cancelQueries({ queryKey: ["base-rows", pageId] });
const sourceKey = baseRowsQueryKey(pageId, sourceColumnFilter, undefined, search);
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined, search);
const groupCountsKey = baseRowGroupCountsKey(pageId, groupByPropertyId, viewFilter, search);
const sourceSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(sourceKey);
const destSnapshot = queryClient.getQueryData<InfiniteData<IBaseRowsPage>>(destKey);
const snapshots: KanbanMoveCardContext["snapshots"] = [
[sourceKey, sourceSnapshot],
[destKey, destSnapshot],
[groupCountsKey, queryClient.getQueryData(groupCountsKey)],
];
if (columnChanged) {
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(sourceKey, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((r) => r.id !== rowId),
})),
};
});
const movingRow = findRowInInfinite(sourceSnapshot, rowId);
if (movingRow) {
const moved: IBaseRow = {
...movingRow,
cells: { ...movingRow.cells, [groupByPropertyId]: destChoiceValue },
position,
};
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (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, moved] }
: page,
),
};
});
}
adjustGroupCount(pageId, groupByPropertyId, viewFilter, search, sourceColumnKey, -1);
adjustGroupCount(pageId, groupByPropertyId, viewFilter, search, destColumnKey, 1);
} else {
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((r) =>
r.id === rowId ? { ...r, position } : r,
),
})),
};
});
}
return { snapshots };
},
onError: (_, __, context) => {
if (context?.snapshots) {
for (const [key, data] of context.snapshots) {
queryClient.setQueryData(key, data);
}
}
notifications.show({
message: t("Failed to move card"),
color: "red",
});
},
});
}
type KanbanCreateCardInput = {
pageId: string;
destColumnFilter: FilterNode | undefined;
groupByPropertyId: string;
columnKey: string;
position?: string;
viewFilter?: FilterNode;
search?: SearchSpec;
};
export function useKanbanCreateCardMutation() {
const { t } = useTranslation();
return useMutation<IBaseRow, Error, KanbanCreateCardInput>({
mutationFn: ({ pageId, groupByPropertyId, columnKey, position }) =>
createRow({
pageId,
cells: columnKey === NO_VALUE_CHOICE_ID ? {} : { [groupByPropertyId]: columnKey },
position,
requestId: newRequestId(),
}),
onSuccess: (newRow, variables) => {
const { pageId, destColumnFilter, groupByPropertyId, columnKey, viewFilter, search } = variables;
const destKey = baseRowsQueryKey(pageId, destColumnFilter, undefined, search);
queryClient.setQueryData<InfiniteData<IBaseRowsPage>>(destKey, (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, newRow] }
: page,
),
};
});
adjustGroupCount(pageId, groupByPropertyId, viewFilter, search, columnKey, 1);
},
onError: () => {
notifications.show({
message: t("Failed to add card"),
color: "red",
});
},
});
}
export function invalidateKanbanColumns(pageId: string) {
queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
queryClient.invalidateQueries({ queryKey: ["base-rows-count", pageId] });
queryClient.invalidateQueries({ queryKey: ["base-row-group-counts", pageId] });
}
@@ -25,6 +25,8 @@ import {
ViewSortConfig, ViewSortConfig,
CountRowsInput, CountRowsInput,
CountRowsResult, CountRowsResult,
GroupCountsInput,
GroupCountsResult,
RowReferences, RowReferences,
} from "@/ee/base/types/base.types"; } from "@/ee/base/types/base.types";
import { IPagination } from "@/lib/types"; import { IPagination } from "@/lib/types";
@@ -33,8 +35,6 @@ export type IBaseRowsPage = IPagination<IBaseRow> & {
references?: RowReferences; references?: RowReferences;
}; };
// --- Bases ---
export async function createBase(data: CreateBaseInput): Promise<IBase> { export async function createBase(data: CreateBaseInput): Promise<IBase> {
const req = await api.post<IBase>("/bases/create", data); const req = await api.post<IBase>("/bases/create", data);
return req.data; return req.data;
@@ -54,8 +54,11 @@ export async function deleteBase(pageId: string): Promise<void> {
await api.post("/bases/delete", { pageId }); await api.post("/bases/delete", { pageId });
} }
export async function convertPageToBase(pageId: string): Promise<IBase> { export async function convertPageToBase(
const req = await api.post<IBase>("/bases/convert", { pageId }); pageId: string,
template?: "kanban",
): Promise<IBase> {
const req = await api.post<IBase>("/bases/convert", { pageId, template });
return req.data; return req.data;
} }
@@ -87,8 +90,6 @@ export async function listBases(
return req.data; return req.data;
} }
// --- Properties ---
export async function createProperty( export async function createProperty(
data: CreatePropertyInput, data: CreatePropertyInput,
): Promise<IBaseProperty> { ): Promise<IBaseProperty> {
@@ -116,8 +117,6 @@ export async function reorderProperty(
await api.post("/bases/properties/reorder", data); await api.post("/bases/properties/reorder", data);
} }
// --- Rows ---
export async function createRow(data: CreateRowInput): Promise<IBaseRow> { export async function createRow(data: CreateRowInput): Promise<IBaseRow> {
const req = await api.post<IBaseRow>("/bases/rows/create", data); const req = await api.post<IBaseRow>("/bases/rows/create", data);
return req.data; return req.data;
@@ -169,6 +168,16 @@ export async function countRows(
return req.data; return req.data;
} }
export async function groupCountRows(
data: GroupCountsInput,
): Promise<GroupCountsResult> {
const req = await api.post<GroupCountsResult>(
"/bases/rows/group-counts",
data,
);
return req.data;
}
// --- Views --- // --- Views ---
export async function createView(data: CreateViewInput): Promise<IBaseView> { export async function createView(data: CreateViewInput): Promise<IBaseView> {
@@ -0,0 +1,16 @@
import { NO_VALUE_CHOICE_ID, FilterGroup, FilterNode } from "@/ee/base/types/base.types";
import { normalizeFilter } from "@/ee/base/queries/base-row-query";
export function buildColumnFilter(
viewFilter: FilterGroup | undefined,
groupByPropertyId: string,
columnKey: string,
): FilterNode | undefined {
const condition = columnKey === NO_VALUE_CHOICE_ID
? { propertyId: groupByPropertyId, op: "isEmpty" as const }
: { propertyId: groupByPropertyId, op: "eq" as const, value: columnKey };
const children: FilterGroup["children"] = viewFilter?.children?.length
? [viewFilter, condition]
: [condition];
return normalizeFilter({ op: "and", children });
}
@@ -0,0 +1,158 @@
.standalone {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.bandWrap {
flex-shrink: 0;
}
.board {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 12px;
height: 100%;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
padding: 8px var(--embed-grid-pad-right, 12px) 8px var(--embed-grid-pad-left, 12px);
margin-left: calc(-1 * var(--embed-extend-l, 0px));
margin-right: calc(-1 * var(--embed-extend-r, 0px));
overflow-anchor: none;
}
.boardEmbed {
max-height: min(80vh, calc(100vh - var(--sticky-band-top, 0px)));
}
.boardFullPage {
flex: 1;
min-height: 0;
}
.column {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 280px;
min-height: 0;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
border-radius: 10px;
}
.columnHeader {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px 6px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.columnHeaderDragging {
opacity: 0.4;
}
.columnDragHandle {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: -4px;
color: var(--mantine-color-dimmed);
cursor: grab;
opacity: 0;
}
.columnHeader:hover .columnDragHandle {
opacity: 0.45;
}
.columnDragHandle:active {
cursor: grabbing;
}
.cardList {
display: flex;
flex-direction: column;
flex: 1;
gap: 8px;
min-height: 0;
overflow-y: auto;
overflow-anchor: none;
padding: 4px 8px 8px;
}
.card {
position: relative;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-radius: 8px;
box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.25));
padding: 12px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
}
.cardDragging {
opacity: 0.4;
}
.card:hover {
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
}
.card:focus-visible {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: 1px;
}
.cardTitle {
font-size: var(--mantine-font-size-sm);
font-weight: 500;
line-height: 1.3;
word-break: break-word;
}
.cardUntitled {
color: var(--mantine-color-dimmed);
}
.count {
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-sm);
}
.addCard {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
color: var(--mantine-color-dimmed);
border-radius: 8px;
cursor: pointer;
font-size: var(--mantine-font-size-sm);
}
.addCard:hover {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
.addCard:focus-visible {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: 1px;
}
.cardDragPreview {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 8px 10px;
}
@@ -193,8 +193,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* ---------- Field shell ---------- /* Every value renders inside the same shell: identical padding, identical
Every value renders inside the same shell: identical padding, identical
left edge, identical focus treatment. Fields differ only in content. */ left edge, identical focus treatment. Fields differ only in content. */
.fieldShell { .fieldShell {
+21 -3
View File
@@ -25,6 +25,8 @@ export type BasePropertyType =
| 'formula' | 'formula'
| 'longText'; | 'longText';
export type BaseViewType = 'table' | 'kanban' | 'calendar';
export type Choice = { export type Choice = {
id: string; id: string;
name: string; name: string;
@@ -193,6 +195,10 @@ export type SearchSpec = {
export const NO_VALUE_CHOICE_ID = '__no_value'; export const NO_VALUE_CHOICE_ID = '__no_value';
export const KANBAN_CARD_DRAG_TYPE = "base-kanban-card";
export const KANBAN_COLUMN_DRAG_TYPE = "base-kanban-column";
export type KanbanColumn = { key: string; name: string; color?: string; isNoValue: boolean };
export type ViewConfig = { export type ViewConfig = {
sorts?: ViewSortConfig[]; sorts?: ViewSortConfig[];
filter?: FilterGroup; filter?: FilterGroup;
@@ -214,7 +220,7 @@ export type IBaseView = {
id: string; id: string;
pageId: string; pageId: string;
name: string; name: string;
type: 'table' | 'kanban' | 'calendar'; type: BaseViewType;
config: ViewConfig; config: ViewConfig;
position: string; position: string;
workspaceId: string; workspaceId: string;
@@ -301,10 +307,22 @@ export type CountRowsResult = {
count: number; count: number;
}; };
export type GroupCountsInput = {
pageId: string;
groupByPropertyId: string;
filter?: FilterNode;
search?: SearchSpec;
};
export type GroupCountsResult = {
counts: Array<{ value: string | null; count: number }>;
};
export type CreateRowInput = { export type CreateRowInput = {
pageId: string; pageId: string;
cells?: Record<string, unknown>; cells?: Record<string, unknown>;
afterRowId?: string; afterRowId?: string;
position?: string;
requestId?: string; requestId?: string;
}; };
@@ -338,7 +356,7 @@ export type ReorderRowInput = {
export type CreateViewInput = { export type CreateViewInput = {
pageId: string; pageId: string;
name: string; name: string;
type?: 'table' | 'kanban' | 'calendar'; type?: BaseViewType;
config?: ViewConfig; config?: ViewConfig;
}; };
@@ -346,7 +364,7 @@ export type UpdateViewInput = {
viewId: string; viewId: string;
pageId: string; pageId: string;
name?: string; name?: string;
type?: 'table' | 'kanban' | 'calendar'; type?: BaseViewType;
config?: ViewConfigPatch; config?: ViewConfigPatch;
position?: string; position?: string;
}; };
@@ -10,6 +10,8 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>(""); export const yjsConnectionStatusAtom = atom<string>("");
export const yjsSyncedAtom = atom<boolean>(false);
export const showAiMenuAtom = atom(false); export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false); export const showLinkMenuAtom = atom(false);
@@ -7,6 +7,9 @@ import { useBaseQuery } from "@/ee/base/queries/base-query";
import { pinOffsetWatcher } from "@docmost/editor-ext"; import { pinOffsetWatcher } from "@docmost/editor-ext";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { IconTable } from "@tabler/icons-react";
import { usePageQuery } from "@/features/page/queries/page-query";
import classes from "./base-embed.module.css";
const SIDE_GUTTER = 8; const SIDE_GUTTER = 8;
@@ -56,6 +59,7 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
const { data: base, isLoading, isError } = useBaseQuery( const { data: base, isLoading, isError } = useBaseQuery(
pendingKey ? "" : pageId ?? "", pendingKey ? "" : pageId ?? "",
); );
const { data: page } = usePageQuery({ pageId: pageId ?? undefined });
useEffect(() => { useEffect(() => {
const wrapper = wrapperRef.current; const wrapper = wrapperRef.current;
@@ -129,7 +133,11 @@ export function BaseEmbedView({ node, editor }: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper> <NodeViewWrapper className={classes.handleGutter}>
<div data-drag-preview hidden className={classes.dragPreview}>
<IconTable size={16} />
<span>{page?.title?.trim() || "Untitled base"}</span>
</div>
<div ref={wrapperRef} style={{ minHeight: isCompact ? undefined : 200 }}> <div ref={wrapperRef} style={{ minHeight: isCompact ? undefined : 200 }}>
{content} {content}
</div> </div>
@@ -0,0 +1,44 @@
.handleGutter {
margin-left: -1.5rem;
padding-left: 1.5rem;
}
@media (max-width: 48em) {
.handleGutter {
margin-left: -1rem;
padding-left: 1rem;
}
}
.dragPreview {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
}
.dragPreview[hidden] {
display: none;
}
.dragPreview svg {
flex-shrink: 0;
}
.dragPreview span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1,10 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@mantine/core"; import { Button } from "@mantine/core";
import { IconTable } from "@tabler/icons-react"; import { IconTable, IconLayoutKanban } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query"; import { useConvertPageToBaseMutation } from "@/ee/base/queries/base-query";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; import {
pageEditorAtom,
yjsSyncedAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import classes from "./empty-page-get-started.module.css"; import classes from "./empty-page-get-started.module.css";
@@ -20,6 +23,7 @@ export function EmptyPageGetStarted({
}: EmptyPageGetStartedProps) { }: EmptyPageGetStartedProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const editor = useAtomValue(pageEditorAtom); const editor = useAtomValue(pageEditorAtom);
const isSynced = useAtomValue(yjsSyncedAtom);
const hasBases = useHasFeature(Feature.BASES); const hasBases = useHasFeature(Feature.BASES);
const convertMutation = useConvertPageToBaseMutation(); const convertMutation = useConvertPageToBaseMutation();
@@ -36,7 +40,7 @@ export function EmptyPageGetStarted({
}; };
}, [editor]); }, [editor]);
if (!editable || !hasBases || !editor || !isEmpty) return null; if (!editable || !hasBases || !editor || !isSynced || !isEmpty) return null;
const chips = [ const chips = [
{ {
@@ -46,6 +50,13 @@ export function EmptyPageGetStarted({
onClick: () => convertMutation.mutate({ pageId }), onClick: () => convertMutation.mutate({ pageId }),
disabled: convertMutation.isPending, disabled: convertMutation.isPending,
}, },
{
key: "kanban",
label: t("Kanban"),
icon: IconLayoutKanban,
onClick: () => convertMutation.mutate({ pageId, template: "kanban" }),
disabled: convertMutation.isPending,
},
]; ];
return ( return (
@@ -7,6 +7,7 @@ import {
IconH2, IconH2,
IconH3, IconH3,
IconInfoCircle, IconInfoCircle,
IconLayoutKanban,
IconList, IconList,
IconListNumbers, IconListNumbers,
IconMath, IconMath,
@@ -443,6 +444,62 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
}, },
}, },
{
title: "Kanban",
description: "Insert a kanban board on this page",
searchTerms: ["kanban", "board", "cards", "status", "task"],
icon: IconLayoutKanban,
command: async ({ editor, range }: CommandProps) => {
// @ts-ignore
const parentPageId = editor.storage?.pageId as string | undefined;
if (!parentPageId) return;
const pendingKey = uuid7();
editor
.chain()
.focus()
.deleteRange(range)
.insertBaseEmbed({ pageId: null, pendingKey })
.run();
try {
const res = await api.post<{ id: string }>("/bases/create", {
parentPageId,
template: "kanban",
});
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
if (pos === null) return;
editor
.chain()
.command(({ tr }) => {
tr.setNodeMarkup(pos, undefined, {
pageId: res.data.id,
pendingKey: null,
});
return true;
})
.run();
} catch {
const pos = findBaseEmbedPlaceholderPos(editor, pendingKey);
if (pos !== null) {
editor
.chain()
.command(({ tr }) => {
const node = tr.doc.nodeAt(pos);
if (node) tr.delete(pos, pos + node.nodeSize);
return true;
})
.run();
}
notifications.show({
message: "Failed to create base",
color: "red",
});
}
},
},
{ {
title: "Toggle block", title: "Toggle block",
description: "Insert collapsible block.", description: "Insert collapsible block.",
@@ -34,6 +34,8 @@ export interface GlobalDragHandleOptions {
* Custom nodes to be included for drag handle * Custom nodes to be included for drag handle
*/ */
customNodes: string[]; customNodes: string[];
atomNodes: string[];
} }
function absoluteRect(node: Element) { function absoluteRect(node: Element) {
const data = node.getBoundingClientRect(); const data = node.getBoundingClientRect();
@@ -76,6 +78,10 @@ function nodeDOMAtCoords(
`[data-type=${node}] p`, `[data-type=${node}] p`,
`.node-${node} p`, `.node-${node} p`,
]); ]);
const atomSelectors = options.atomNodes.flatMap((node) => [
`[data-type=${node}]`,
`.node-${node}`,
]);
const selectors = [ const selectors = [
"li", "li",
@@ -95,8 +101,9 @@ function nodeDOMAtCoords(
".tableWrapper", ".tableWrapper",
...customParagraphSelectors, ...customParagraphSelectors,
...customSelectors, ...customSelectors,
...atomSelectors,
].join(", "); ].join(", ");
return document const found = document
.elementsFromPoint(coords.x, coords.y) .elementsFromPoint(coords.x, coords.y)
.find((elem: Element) => { .find((elem: Element) => {
// Skip elements that belong to a nested editor (e.g. transclusion // Skip elements that belong to a nested editor (e.g. transclusion
@@ -108,6 +115,11 @@ function nodeDOMAtCoords(
elem.matches(selectors) elem.matches(selectors)
); );
}); });
if (found && atomSelectors.length > 0) {
const atomWrapper = found.closest(atomSelectors.join(", "));
if (atomWrapper) return atomWrapper;
}
return found;
} }
function nodePosAtDOM( function nodePosAtDOM(
node: Element, node: Element,
@@ -127,7 +139,7 @@ function isCustomNodeDOM(
options: GlobalDragHandleOptions, options: GlobalDragHandleOptions,
): boolean { ): boolean {
if (!elem) return false; if (!elem) return false;
for (const name of options.customNodes) { for (const name of [...options.customNodes, ...options.atomNodes]) {
if ( if (
elem.getAttribute("data-type") === name || elem.getAttribute("data-type") === name ||
elem.classList.contains(`node-${name}`) elem.classList.contains(`node-${name}`)
@@ -210,7 +222,10 @@ export function DragHandlePlugin(
// The drag landed on a custom-node container (transclusion etc.). // The drag landed on a custom-node container (transclusion etc.).
// Walk up to the matching node so the drag moves the whole // Walk up to the matching node so the drag moves the whole
// container, not whatever inner element the click landed on. // container, not whatever inner element the click landed on.
const customTypes = new Set(options.customNodes); const customTypes = new Set([
...options.customNodes,
...options.atomNodes,
]);
for (let d = $sel.depth; d > 0; d--) { for (let d = $sel.depth; d > 0; d--) {
if (customTypes.has($sel.node(d).type.name)) { if (customTypes.has($sel.node(d).type.name)) {
selection = NodeSelection.create( selection = NodeSelection.create(
@@ -264,7 +279,23 @@ export function DragHandlePlugin(
event.dataTransfer.setData("text/plain", text); event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setDragImage(node, 0, 0); const previewTemplate =
node.querySelector<HTMLElement>("[data-drag-preview]");
if (previewTemplate) {
const preview = previewTemplate.cloneNode(true) as HTMLElement;
preview.removeAttribute("hidden");
preview.style.position = "fixed";
preview.style.top = "0";
preview.style.left = "-10000px";
preview.style.pointerEvents = "none";
document.body.appendChild(preview);
event.dataTransfer.setDragImage(preview, 0, 0);
document.addEventListener("dragend", () => preview.remove(), {
once: true,
});
} else {
event.dataTransfer.setDragImage(node, 0, 0);
}
view.dragging = { slice, move: event.ctrlKey }; view.dragging = { slice, move: event.ctrlKey };
} }
@@ -497,6 +528,7 @@ const GlobalDragHandle = Extension.create({
scrollThreshold: 100, scrollThreshold: 100,
excludedTags: [], excludedTags: [],
customNodes: [], customNodes: [],
atomNodes: [],
}; };
}, },
@@ -509,6 +541,7 @@ const GlobalDragHandle = Extension.create({
dragHandleSelector: this.options.dragHandleSelector, dragHandleSelector: this.options.dragHandleSelector,
excludedTags: this.options.excludedTags, excludedTags: this.options.excludedTags,
customNodes: this.options.customNodes, customNodes: this.options.customNodes,
atomNodes: this.options.atomNodes,
}), }),
]; ];
}, },
@@ -233,6 +233,7 @@ export const mainExtensions = [
TrailingNode, TrailingNode,
GlobalDragHandle.configure({ GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference"], customNodes: ["transclusionSource", "transclusionReference"],
atomNodes: ["base"],
}), }),
TextStyle, TextStyle,
Color, Color,
@@ -34,6 +34,7 @@ import {
currentPageEditModeAtom, currentPageEditModeAtom,
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
yjsSyncedAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { import {
@@ -109,6 +110,7 @@ export default function PageEditor({
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
); );
const [, setYjsSynced] = useAtom(yjsSyncedAtom);
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
@@ -378,6 +380,14 @@ export default function PageEditor({
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
useEffect(() => {
setYjsSynced(isSynced);
}, [isSynced, setYjsSynced]);
useEffect(() => {
return () => setYjsSynced(false);
}, [setYjsSynced]);
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) { if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
@@ -87,8 +87,6 @@
padding: 0; padding: 0;
} }
/* ---- pragmatic-tree additions ---- */
.rowWrapper { .rowWrapper {
position: relative; position: relative;
display: flex; display: flex;
+2 -2
View File
@@ -107,7 +107,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
// Height: see `.base-page-root` in core.css. // Height: see `.base-page-root` in core.css.
// Clear the fixed PageHeader (breadcrumb) plus a little extra so the // Clear the fixed PageHeader (breadcrumb) plus a little extra so the
// pinned column-header row isn't tucked half under it. // pinned column-header row isn't tucked half under it.
paddingTop: "calc(var(--page-header-height) + 18px)", paddingTop: "calc(var(--page-header-height) + 6px)",
}} }}
> >
<Helmet> <Helmet>
@@ -137,7 +137,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
titleSlot={ titleSlot={
<div <div
className="base-page-title" className="base-page-title"
style={{ paddingTop: 32, paddingBottom: 12 }} style={{ paddingTop: 2, paddingBottom: 6 }}
> >
<MemoizedTitleEditor <MemoizedTitleEditor
pageId={page.id} pageId={page.id}
+1
View File
@@ -38,6 +38,7 @@
"@aws-sdk/s3-request-presigner": "3.1050.0", "@aws-sdk/s3-request-presigner": "3.1050.0",
"@azure/storage-blob": "12.31.0", "@azure/storage-blob": "12.31.0",
"@clickhouse/client": "^1.18.2", "@clickhouse/client": "^1.18.2",
"@docmost/base-formula": "workspace:*",
"@docmost/pdf-inspector": "1.9.6", "@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
+3
View File
@@ -513,6 +513,9 @@ importers:
'@clickhouse/client': '@clickhouse/client':
specifier: ^1.18.2 specifier: ^1.18.2
version: 1.18.2 version: 1.18.2
'@docmost/base-formula':
specifier: workspace:*
version: link:../../packages/base-formula
'@docmost/pdf-inspector': '@docmost/pdf-inspector':
specifier: 1.9.6 specifier: 1.9.6
version: 1.9.6 version: 1.9.6