diff --git a/Dockerfile b/Dockerfile index d665e254b..85d39981e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,8 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json # Copy packages 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/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 --from=builder /app/package.json /app/package.json diff --git a/apps/client/src/ee/base/components/base-toolbar.tsx b/apps/client/src/ee/base/components/base-toolbar.tsx index 935b6862e..578eaa574 100644 --- a/apps/client/src/ee/base/components/base-toolbar.tsx +++ b/apps/client/src/ee/base/components/base-toolbar.tsx @@ -7,6 +7,8 @@ import { IconEye, IconDownload, IconArrowsDiagonal, + IconLayoutColumns, + IconAdjustments, } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; 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 { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config"; 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 classes from "@/ee/base/styles/grid.module.css"; import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css"; type BaseToolbarProps = { 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; views: IBaseView[]; - table: Table; + table?: Table; onViewChange: (viewId: string) => void; onAddView?: () => void; + canAddView?: boolean; onPersistViewConfig: () => void; onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void; onDraftFiltersChange: (filter: FilterGroup | undefined) => void; @@ -50,6 +53,7 @@ export function BaseToolbar({ table, onViewChange, onAddView, + canAddView, onPersistViewConfig, onDraftSortsChange, onDraftFiltersChange, @@ -61,8 +65,11 @@ export function BaseToolbar({ const [sortOpened, setSortOpened] = useState(false); const [filterOpened, setFilterOpened] = useState(false); const [propertiesOpened, setPropertiesOpened] = useState(false); + const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false); const [exporting, setExporting] = useState(false); + const isKanban = activeView?.type === "kanban"; + const handleExport = useCallback(async () => { if (exporting) return; setExporting(true); @@ -85,8 +92,6 @@ export function BaseToolbar({ }, []); 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(() => { const filter = activeView?.config?.filter; if (!filter || filter.op !== "and") return []; @@ -96,13 +101,13 @@ export function BaseToolbar({ }, [activeView?.config?.filter]); const hiddenPropertyCount = useMemo(() => { + if (!table) return 0; const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number"); return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length; - }, [table, table.getState().columnVisibility]); + }, [table, table?.getState().columnVisibility]); const handleSortsChange = useCallback( (newSorts: ViewSortConfig[]) => { - // Normalize empty to undefined so the draft hook drops the sorts axis. onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); }, [onDraftSortsChange], @@ -110,7 +115,6 @@ export function BaseToolbar({ const handleFiltersChange = useCallback( (newConditions: FilterCondition[]) => { - // Wrap the AND-flat list into the engine's FilterGroup shape; undefined drops the axis. const filter: FilterGroup | undefined = newConditions.length > 0 ? { op: "and", children: newConditions } @@ -128,6 +132,8 @@ export function BaseToolbar({ pageId={base.id} onViewChange={onViewChange} onAddView={onAddView} + base={base} + canAddView={canAddView} getViewShareUrl={getViewShareUrl} /> @@ -175,63 +181,104 @@ export function BaseToolbar({ - setSortOpened(false)} - sorts={sorts} - properties={base.properties} - onChange={handleSortsChange} - > - - 0 ? "blue" : "gray"} - onClick={() => openToolbar("sort")} - > - - {sorts.length > 0 && ( - + + + - {sorts.length} - - )} - - - + + + + - setPropertiesOpened(false)} - table={table} - properties={base.properties} - onPersist={onPersistViewConfig} - > - - 0 ? "blue" : "gray"} - onClick={() => openToolbar("properties")} + setCardPropertiesOpened(false)} + base={base} + view={activeView} + pageId={base.id} > - - {hiddenPropertyCount > 0 && ( - + setCardPropertiesOpened((v) => !v)} > - {hiddenPropertyCount} - - )} - - - + + + + + + )} + + {!isKanban && ( + <> + setSortOpened(false)} + sorts={sorts} + properties={base.properties} + onChange={handleSortsChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("sort")} + > + + {sorts.length > 0 && ( + + {sorts.length} + + )} + + + + + {table && ( + setPropertiesOpened(false)} + table={table} + properties={base.properties} + onPersist={onPersistViewConfig} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("properties")} + > + + {hiddenPropertyCount > 0 && ( + + {hiddenPropertyCount} + + )} + + + + )} + + )} {onExpand && ( diff --git a/apps/client/src/ee/base/components/base-view.tsx b/apps/client/src/ee/base/components/base-view.tsx index 3a963cf1e..331e24935 100644 --- a/apps/client/src/ee/base/components/base-view.tsx +++ b/apps/client/src/ee/base/components/base-view.tsx @@ -22,10 +22,7 @@ import { useUpdateRowMutation, useReorderRowMutation, } from "@/ee/base/queries/base-row-query"; -import { - useCreateViewMutation, - useUpdateViewMutation, -} from "@/ee/base/queries/base-view-query"; +import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query"; import { activeViewIdAtomFamily, editingCellAtomFamily, @@ -51,6 +48,7 @@ import { getAppUrl } from "@/lib/config.ts"; import { useNavigate } from "react-router-dom"; import classes from "@/ee/base/styles/grid.module.css"; import viewClasses from "@/ee/base/styles/base-view.module.css"; +import kanbanClasses from "@/ee/base/styles/kanban.module.css"; type BaseViewProps = { pageId: string; @@ -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 // config resolves, which would double network traffic for sorted/filtered views. + const isKanban = activeView?.type === "kanban"; + const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage, - } = useBaseRowsQuery(base ? pageId : undefined, activeFilter, activeSorts); + } = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts); // Warm the count cache alongside the rows query. Gate on currentUser so // 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 createRowMutation = useCreateRowMutation(); const reorderRowMutation = useReorderRowMutation(); - const createViewMutation = useCreateViewMutation(); const updateViewMutation = useUpdateViewMutation(); useEffect(() => { @@ -263,15 +262,6 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV [setActiveViewId], ); - const handleAddView = useCallback(() => { - if (!editable) return; - createViewMutation.mutate({ - pageId, - name: t("New view"), - type: "table", - }); - }, [editable, pageId, createViewMutation, t]); - const handleColumnReorder = useCallback( (columnId: string, finishIndex: number) => { const order = table.getState().columnOrder; @@ -374,7 +364,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV [editable, pageId, reorderRow], ); - if (baseLoading || rowsLoading) { + if (baseLoading || (!isKanban && rowsLoading)) { return ; } if (baseError) { @@ -407,7 +397,7 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV views={views} table={table} onViewChange={handleViewChange} - onAddView={editable ? handleAddView : undefined} + canAddView={editable} onPersistViewConfig={guardedPersistViewConfig} onDraftSortsChange={handleDraftSortsChange} onDraftFiltersChange={handleDraftFiltersChange} @@ -416,38 +406,71 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV /> ); + const kanbanBand = ( +
+ {embedded ? null : titleSlot} + {banner} + {toolbar} + {embedded ? : null} +
+ ); + + const viewRenderer = (folded: React.ReactNode) => ( + + ); + if (embedded) { + if (isKanban) { + return ( + + + {kanbanBand} + {viewRenderer(null)} + + + + ); + } + // Banner and toolbar go into aboveBand so they scroll with the host document; // only the column-header row stays pinned (via --sticky-band-top). return ( - - {banner} - {toolbar} - - - } - /> + {viewRenderer( + <> + {banner} + {toolbar} + + , + )} +
+ + {kanbanBand} + {viewRenderer(null)} + +
+ +
+ ); + } + // Standalone: title, banner, and toolbar go in aboveBand inside the scroll // container so they scroll away; only the column-header row stays pinned. return ( @@ -467,32 +510,13 @@ export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseV
- - {titleSlot} - {banner} - {toolbar} - - } - /> + {viewRenderer( + <> + {titleSlot} + {banner} + {toolbar} + , + )}
diff --git a/apps/client/src/ee/base/components/cells/cell-created-at.tsx b/apps/client/src/ee/base/components/cells/cell-created-at.tsx index 9d0f93225..21286843c 100644 --- a/apps/client/src/ee/base/components/cells/cell-created-at.tsx +++ b/apps/client/src/ee/base/components/cells/cell-created-at.tsx @@ -1,4 +1,5 @@ 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"; type CellCreatedAtProps = { @@ -10,21 +11,8 @@ type CellCreatedAtProps = { 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) { - const formatted = formatTimestamp(value); + const formatted = formatTimestamp(typeof value === "string" ? value : null); if (!formatted) { return ; diff --git a/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx index 325701db8..efad990de 100644 --- a/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx +++ b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx @@ -1,4 +1,5 @@ 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"; type CellLastEditedAtProps = { @@ -10,21 +11,8 @@ type CellLastEditedAtProps = { 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) { - const formatted = formatTimestamp(value); + const formatted = formatTimestamp(typeof value === "string" ? value : null); if (!formatted) { return ; diff --git a/apps/client/src/ee/base/components/cells/cell-long-text.tsx b/apps/client/src/ee/base/components/cells/cell-long-text.tsx index b2c421f37..756ec09f7 100644 --- a/apps/client/src/ee/base/components/cells/cell-long-text.tsx +++ b/apps/client/src/ee/base/components/cells/cell-long-text.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core"; import { useDebouncedCallback } from "@mantine/hooks"; 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"; type CellLongTextProps = { @@ -68,7 +69,7 @@ export function CellLongText({ onCancel(); }; - const preview = toText(value).replace(/\s+/g, " ").trim(); + const preview = formatLongTextPreview(toText(value)); return ( ; } -function PersonReadList({ - personIds, - users, -}: { - personIds: string[]; - users: Record; -}) { - const entries = personIds.map((id) => ({ - id, - name: users[id]?.name ?? id.substring(0, 8), - avatarUrl: users[id]?.avatarUrl ?? "", - })); - const chips = entries.map((entry) => ( - - - {entry.name} - - )); - return ( - `${e.id}:${e.name}`).join("|")} - tooltipLabel={entries.map((e) => e.name).join(", ")} - /> - ); -} diff --git a/apps/client/src/ee/base/components/cells/choice-color.ts b/apps/client/src/ee/base/components/cells/choice-color.ts index 2822cc141..20a4fbb6d 100644 --- a/apps/client/src/ee/base/components/cells/choice-color.ts +++ b/apps/client/src/ee/base/components/cells/choice-color.ts @@ -2,7 +2,7 @@ import { CSSProperties } from "react"; const colorMap: Record = { 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" }, grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" }, violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" }, diff --git a/apps/client/src/ee/base/components/cells/person-read-list.tsx b/apps/client/src/ee/base/components/cells/person-read-list.tsx new file mode 100644 index 000000000..44a0318f4 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/person-read-list.tsx @@ -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; +}; + +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) => ( + + + {entry.name} + + )); + return ( + `${e.id}:${e.name}`).join("|")} + tooltipLabel={entries.map((e) => e.name).join(", ")} + /> + ); +} diff --git a/apps/client/src/ee/base/components/kanban/base-kanban.tsx b/apps/client/src/ee/base/components/kanban/base-kanban.tsx new file mode 100644 index 000000000..f08062a18 --- /dev/null +++ b/apps/client/src/ee/base/components/kanban/base-kanban.tsx @@ -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(null); + useKanbanBoardAutoScroll(boardRef, pageId); + + const cardRefs = useRef>(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>(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 ; + } + + return ( +
+ {columns.map((column) => ( + + ))} +
+ ); +} diff --git a/apps/client/src/ee/base/components/kanban/card-field/card-field.tsx b/apps/client/src/ee/base/components/kanban/card-field/card-field.tsx new file mode 100644 index 000000000..15ee9b73c --- /dev/null +++ b/apps/client/src/ee/base/components/kanban/card-field/card-field.tsx @@ -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 ; + case "longText": + return ; + case "number": + return ; + case "select": + case "status": + return ; + case "multiSelect": + return ; + case "date": + return ; + case "createdAt": + case "lastEditedAt": + return ; + case "person": + return ; + case "lastEditedBy": + return ; + case "file": + return ; + case "page": + return ; + case "checkbox": + return ; + case "url": + return ; + case "email": + return ; + case "formula": + return ; + default: + return ( + + {String(value)} + + ); + } +} + +function TextField({ value }: { value: unknown }) { + const text = typeof value === "string" ? value : String(value); + if (!text) return null; + return ( + + {text} + + ); +} + +function LongTextField({ value }: { value: unknown }) { + const preview = formatLongTextPreview(typeof value === "string" ? value : undefined); + if (!preview) return null; + return ( + + {preview} + + ); +} + +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 {formatted}; +} + +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 ( + + ); +} + +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) => ( + + {choice.name} + + )); + return ( + `${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 ( + + {formatted} + + ); +} + +function TimestampField({ value }: { value: unknown }) { + const formatted = formatTimestamp(typeof value === "string" ? value : null); + if (!formatted) return null; + return ( + + {formatted} + + ); +} + +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 ; +} + +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 ( + + + + + {name} + + + + ); +} + +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 ( +
+ {visible.map((file) => ( + + {file.fileName} + + ))} + {overflow > 0 && +{overflow}} +
+ ); +} + +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 ( + + + Page not found + + ); + } + + const title = getPageTitle(resolvedPage.title, undefined, t); + const spaceSlug = resolvedPage.space?.slug ?? ""; + const url = buildPageUrl(spaceSlug, resolvedPage.slugId, title); + + return ( + + e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {resolvedPage.icon ? ( + {resolvedPage.icon} + ) : ( + + )} + {title} + + + ); +} + +function CheckboxField({ value }: { value: unknown }) { + if (value !== true) return null; + return ; +} + +function UrlField({ value }: { value: unknown }) { + const displayValue = typeof value === "string" ? value : ""; + if (!displayValue) return null; + const safeHref = sanitizeUrl(displayValue); + if (!safeHref) { + return ( + + {displayValue} + + ); + } + return ( + + e.stopPropagation()} + style={{ fontSize: "var(--mantine-font-size-xs)" }} + > + {displayValue} + + + ); +} + +function EmailField({ value }: { value: unknown }) { + const displayValue = typeof value === "string" ? value : ""; + if (!displayValue) return null; + return ( + + e.stopPropagation()} + style={{ fontSize: "var(--mantine-font-size-xs)" }} + > + {displayValue} + + + ); +} + +function FormulaField({ value, property }: { value: unknown; property: IBaseProperty }) { + if (isFormulaErrorCell(value)) { + return ( + + + #ERROR + + + ); + } + + const opts = (property.typeOptions ?? {}) as { resultType?: string }; + const resultType = opts.resultType ?? "null"; + + if (resultType === "number") { + return ; + } + if (resultType === "boolean") { + return ; + } + if (resultType === "date") { + return ; + } + + const text = typeof value === "string" ? value : value != null ? String(value) : null; + if (!text) return null; + return ( + + {text} + + ); +} diff --git a/apps/client/src/ee/base/components/kanban/kanban-add-card-button.tsx b/apps/client/src/ee/base/components/kanban/kanban-add-card-button.tsx new file mode 100644 index 000000000..a23e833ca --- /dev/null +++ b/apps/client/src/ee/base/components/kanban/kanban-add-card-button.tsx @@ -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 ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onAddCard(); + } + }} + > + + {t("New row")} +
+ ); +} diff --git a/apps/client/src/ee/base/components/kanban/kanban-card-properties.tsx b/apps/client/src/ee/base/components/kanban/kanban-card-properties.tsx new file mode 100644 index 000000000..cd3021f93 --- /dev/null +++ b/apps/client/src/ee/base/components/kanban/kanban-card-properties.tsx @@ -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 ( + { + if (!o) onClose(); + }} + onClose={onClose} + position="bottom-end" + shadow="md" + width={260} + trapFocus + closeOnEscape + closeOnClickOutside + withinPortal + > + {children} + + + + + {t("Card properties")} + + + + + {primaryProperty && ( +
+
+ +
+ + {PrimaryIcon && } + + {primaryProperty.name} + + + {}} + styles={{ track: { cursor: "default" } }} + /> +
+ )} + {orderedProperties.map((p) => { + const isVisible = visibleIds.includes(p.id); + const typeConfig = propertyTypes.find((pt) => pt.type === p.type); + const TypeIcon = typeConfig?.icon; + return ( + + ); + })} +
+
+
+
+
+ ); +} + +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(null); + const handleRef = useRef(null); + + const [isDragging, setIsDragging] = useState(false); + const [closestEdge, setClosestEdge] = useState(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 ( +
+ onToggle(property.id, !isVisible)} + style={{ paddingLeft: 4 }} + > +
e.stopPropagation()}> + +
+ + {TypeIcon && } + + {property.name} + + + {}} + onClick={(e) => e.stopPropagation()} + styles={{ track: { cursor: "pointer" } }} + /> +
+ {closestEdge && } +
+ ); +} diff --git a/apps/client/src/ee/base/components/kanban/kanban-card.tsx b/apps/client/src/ee/base/components/kanban/kanban-card.tsx new file mode 100644 index 000000000..32993e94d --- /dev/null +++ b/apps/client/src/ee/base/components/kanban/kanban-card.tsx @@ -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( + 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(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 ( +
onOpen(row.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(row.id); + } + }} + > + {closestEdge === "top" && } +
+ {title || t("Untitled")} +
+ {cardProps.map((property) => ( + + ))} + {closestEdge === "bottom" && } +
+ ); + }, +); diff --git a/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx b/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx new file mode 100644 index 000000000..42ebf66f2 --- /dev/null +++ b/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx @@ -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(null); + const handleRef = useRef(null); + const { closestEdge, isDragging } = useKanbanColumnDnd({ + headerRef, + handleRef, + columnKey: column.key, + pageId, + }); + + return ( +
+ {canEdit && ( +
+ +
+ )} +
+ + {column.isNoValue ? t("No value") : column.name} + + {count !== undefined && {count}} + {canEdit && ( + <> + + + + + + + + {t("Hide group")} + + + + + + + )} + {closestEdge && } +
+ ); +} diff --git a/apps/client/src/ee/base/components/kanban/kanban-column.tsx b/apps/client/src/ee/base/components/kanban/kanban-column.tsx new file mode 100644 index 000000000..c5124f049 --- /dev/null +++ b/apps/client/src/ee/base/components/kanban/kanban-column.tsx @@ -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(); + 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(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 ( +
+ onHide(column.key)} + onAddCard={() => addCard("top")} + /> +
+ {rows.map((row) => ( + registerCardRef(row.id, column.key, el)} + /> + ))} + {canEdit && addCard("bottom")} />} +
+
+ ); +} diff --git a/apps/client/src/ee/base/components/kanban/kanban-empty-state.tsx b/apps/client/src/ee/base/components/kanban/kanban-empty-state.tsx new file mode 100644 index 000000000..c62f0aae5 --- /dev/null +++ b/apps/client/src/ee/base/components/kanban/kanban-empty-state.tsx @@ -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 ( + + {t("This board has no grouping property yet.")} + + ); + } + + return ( + + {t("Group this board by a select or status property.")} + {groupableProperties.length > 0 ? ( + + {hasValidGroupBy && allGroups.length > 0 && ( + + + {t("Groups")} + + + {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 ( + toggleGroup(g.key, g.hidden)} + > + + + + {g.isNoValue ? t("No value") : g.name} + + + {}} + onClick={(e) => e.stopPropagation()} + styles={{ track: { cursor: "pointer" } }} + /> + + ); + })} + + + )} + + + + ); +} diff --git a/apps/client/src/ee/base/components/property/create-property-popover.tsx b/apps/client/src/ee/base/components/property/create-property-popover.tsx index 58d5815ff..4c4394f51 100644 --- a/apps/client/src/ee/base/components/property/create-property-popover.tsx +++ b/apps/client/src/ee/base/components/property/create-property-popover.tsx @@ -30,7 +30,7 @@ import classes from "@/ee/base/styles/grid.module.css"; type CreatePropertyPopoverProps = { pageId: string; properties?: IBaseProperty[]; - onPropertyCreated?: () => void; + onPropertyCreated?: (property: IBaseProperty) => void; /** Custom trigger; must return a ref-forwarding element for Popover.Target. * Defaults to the grid's + column button. */ renderTarget?: (open: () => void) => React.ReactElement; @@ -145,8 +145,8 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r : undefined, }, { - onSuccess: () => { - onPropertyCreated?.(); + onSuccess: (created) => { + onPropertyCreated?.(created); }, }, ); @@ -293,7 +293,7 @@ export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, r astVersion: 1, } as TypeOptions, }, - { onSuccess: () => onPropertyCreated?.() }, + { onSuccess: (created) => onPropertyCreated?.(created) }, ); handleClose(); }} diff --git a/apps/client/src/ee/base/components/row-detail-modal/property-row.tsx b/apps/client/src/ee/base/components/row-detail-modal/property-row.tsx index d2a24bde5..d5a6a723e 100644 --- a/apps/client/src/ee/base/components/row-detail-modal/property-row.tsx +++ b/apps/client/src/ee/base/components/row-detail-modal/property-row.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import clsx from "clsx"; import { Popover } from "@mantine/core"; import { IconChevronDown } from "@tabler/icons-react"; @@ -17,6 +17,8 @@ type PropertyRowProps = { onMenuOpenChange: (opened: boolean) => void; onMenuDirtyChange: (dirty: boolean) => void; onUpdate: (propertyId: string, value: unknown) => void; + autoFocusValue?: boolean; + onAutoFocused?: () => void; }; export function PropertyRow({ @@ -27,8 +29,23 @@ export function PropertyRow({ onMenuOpenChange, onMenuDirtyChange, onUpdate, + autoFocusValue, + onAutoFocused, }: PropertyRowProps) { const canEdit = useBaseEditable(); + const rowRef = useRef(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("input, textarea")?.focus(); + } + onAutoFocused?.(); + }, [autoFocusValue, onAutoFocused]); const handleLabelClick = useCallback(() => { onMenuOpenChange(!menuOpened); @@ -48,7 +65,7 @@ export function PropertyRow({ ); return ( -
+
{canEdit ? ( (null); + const [newPropertyId, setNewPropertyId] = useState(null); + const clearNewProperty = useCallback(() => setNewPropertyId(null), []); const menuDirtyRef = useRef(false); const [closeRequest, setCloseRequest] = useAtom( propertyMenuCloseRequestAtomFamily(base.id), @@ -310,6 +312,8 @@ export function RowDetailModal({ property={property} row={row} pageId={base.id} + autoFocusValue={property.id === newPropertyId} + onAutoFocused={clearNewProperty} menuOpened={openMenuId === property.id} onMenuOpenChange={(nextOpened) => handleMenuOpenChange(property.id, nextOpened) @@ -329,6 +333,7 @@ export function RowDetailModal({ setNewPropertyId(p.id)} renderTarget={(open) => (