"use client" import * as React from "react" import { cn } from "../../lib/utils" import type { DataListViewType } from "../../lib/data-list-view" import { DATA_LIST_VIEW_TILES, dataListViewLabel } from "../../lib/data-list-view" import { dataListViewTilesForHub } from "../../lib/data-list-view-registry" import type { RowHeight } from "../../lib/row-height" import { ROW_HEIGHT_TILES } from "../../lib/row-height" import { Input } from "../ui/input" import { FilterByFieldDropdownContent } from "../data-table/filter-by-field-dropdown" import { SelectionTileGrid } from "../ui/selection-tile-grid" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu" import { FloatingSheetPanel, FloatingSheetPanelBody, FloatingSheetPanelContent, FloatingSheetPanelHeader, FloatingSheetPanelToolbar, FloatingSheetPanelWorkflowFooter, } from "../ui/floating-sheet-panel" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select" import { DEFAULT_DATA_LIST_DISPLAY_OPTIONS, type DataListDisplayOptions, } from "../../lib/data-list-display-options" import { Tip } from "../ui/tip" import { ToggleSwitch } from "../ui/toggle-switch" import { Button } from "../ui/button" import { DrawerFilterCard } from "./filter-card" import { DrawerSortCard } from "./sort-card" import { ColumnRow } from "./column-row" import { useDraggableList } from "./draggable-list" import { type ActiveFilter, type SortRule, type ConditionalRule, type FilterFieldDef, RULE_COLORS, } from "../../lib/table-properties-types" import { createConditionalRuleId } from "../../lib/conditional-rule-id" import { PROPERTIES_SHEET_PORTAL_Z, PROPERTIES_SHEET_TOOLTIP_Z, PROPERTIES_SHEET_TOOLTIP_Z_INDEX } from "./sheet-portal-z" const EMPTY_ACTIVE_FILTERS: ActiveFilter[] = [] const EMPTY_SORT_RULES: SortRule[] = [] const EMPTY_CONDITIONAL_RULES: ConditionalRule[] = [] const EMPTY_COL_ORDER: string[] = [] const EMPTY_FILTER_FIELDS: FilterFieldDef[] = [] const EMPTY_FIELD_DEFINITIONS: { key: string; label: string; sortable?: boolean }[] = [] const EMPTY_STRING_SET = new Set() const NOOP = () => {} export interface TablePropertiesDrawerProps { open: boolean onOpenChange: (open: boolean) => void /** * Optional deep-link target. When set (and `open` is true), the drawer * focuses this panel instead of "main" — e.g. the column header's * **Add Conditional Rule** menu item passes `"conditional-rules"` to jump * straight to the conditional-formatting panel. Drawer resets to "main" * automatically when it closes. */ initialPanel?: string | null // Display showGridlines?: boolean onShowGridlinesChange?: (v: boolean) => void rowHeight?: RowHeight onRowHeightChange?: (v: RowHeight) => void pagination?: boolean onPaginationChange?: (v: boolean) => void // Filters activeFilters?: ActiveFilter[] onAddFilter?: (fieldKey: string) => void onUpdateFilter?: (id: string, patch: Partial) => void onRemoveFilter?: (id: string) => void /** How the filter after `leftFilterId` combines with the one above (default "and"). */ getFilterConnector?: (leftFilterId: string) => "and" | "or" onToggleFilterConnector?: (leftFilterId: string) => void filterBarVisible?: boolean onFilterBarVisibleChange?: (v: boolean) => void drawerExpandedFilters?: Set onDrawerExpandedFiltersChange?: React.Dispatch>> totalRows?: number filteredRows?: number // Sort sortRules?: SortRule[] onSortRulesChange?: (rules: SortRule[]) => void onAddSortRule?: (fieldKey: string) => void onRemoveSortRule?: (id: string) => void onToggleSortDir?: (id: string) => void // Columns colOrder?: string[] onColOrderChange?: (order: string[]) => void hiddenCols?: Set onToggleColVisibility?: (key: string) => void onMoveCol?: (key: string, dir: "up" | "down") => void // Group groupBy?: string | null onGroupByChange?: (key: string | null) => void // Sort key for display in main panel primarySortKey?: string // Conditional formatting conditionalRules?: ConditionalRule[] onAddConditionalRule?: (rule: Omit, id?: string) => void onRemoveConditionalRule?: (id: string) => void onUpdateConditionalRule?: (id: string, patch: Partial) => void /** * Filter field defs for drawer + conditional rules. Required — every list-hub * consumer derives this from its column definitions (`columnsToFilterFields`) * and passes it through `drawerToolbarProps` from `HubTable`. There is no * product-specific default in the package. */ filterFields?: FilterFieldDef[] // View type currentView?: DataListViewType onViewChange?: (view: DataListViewType) => void /** * Subset of view types this hub actually implements; when set, the view-type tile grid is * filtered so users cannot switch to an unsupported view from Properties. Defaults to all * registered view types (`DATA_LIST_VIEW_TILES`). */ supportedViewTypes?: readonly DataListViewType[] /** Lifecycle context (e.g. tab filter) — shown in the drawer header */ lifecycleTabLabel?: string /** * Column metadata for the Sort / Group / Columns panels. Required — every * list-hub consumer derives this from its column definitions * (`columnsToFieldDefinitions`) and passes it through `drawerToolbarProps` * from `HubTable`. The drawer never falls back to a default column list. */ fieldDefinitions?: { key: string; label: string; sortable?: boolean }[] resolveColumnLabel?: (key: string) => string /** Shared display options (table + board); persisted at page level. */ displayOptions?: DataListDisplayOptions onDisplayOptionsChange?: (patch: Partial) => void /** * When the active view is Board and more than one entry is provided, shows a control to pick * which field defines swimlane columns (`displayOptions.boardGroupByColumnKey`). */ boardGroupByColumnOptions?: { key: string; label: string }[] /** Optional custom option renderer for filter values (e.g. status chips). */ renderFilterOptionValue?: (fieldKey: string, value: string) => React.ReactNode /** Runtime filter bounds/options derived from hub rows (person lists, numeric ranges). */ filterFieldContext?: Record /** Creation mode for the new 2-step "Add view" flow */ isCreatingNewView?: boolean newViewName?: string onNewViewNameChange?: (name: string) => void onCreateNewView?: () => void /** * Fired when the user dismisses the creation drawer (Cancel button, Esc). * In creation mode this is the cancellation channel — the drawer's * `onOpenChange(false)` also routes here so callers get a single hook for * "creation aborted". When omitted, dismissal falls back to * `onOpenChange(false)`. */ onCancelNewView?: () => void creatingViewTypeLabel?: string /** * Edit-mode rename support — when `onViewNameChange` is set and the drawer is * NOT in creation mode (`!isCreatingNewView`), the main panel renders the same * "Name" input shape as creation mode, bound to `(viewName, onViewNameChange)`. * The drawer just emits; the consumer decides whether to commit on every * keystroke or onBlur. Omit both props to keep the existing read-only header. */ viewName?: string onViewNameChange?: (name: string) => void /** Hub-level counts toggle (global) */ showViewCounts?: boolean onShowViewCountsChange?: (show: boolean) => void } type SheetPanel = "main" | "table-display" | "filter" | "sort" | "group" | "columns" | "conditional-rules" export function TablePropertiesDrawer({ open, onOpenChange, initialPanel, // Non-creation props — provide safe defaults when using the 2-step "Create view" surface showGridlines = true, onShowGridlinesChange = NOOP, pagination = true, onPaginationChange = NOOP, activeFilters = EMPTY_ACTIVE_FILTERS, onAddFilter = NOOP, onUpdateFilter = NOOP, onRemoveFilter = NOOP, getFilterConnector = () => "and", onToggleFilterConnector = NOOP, filterBarVisible = true, onFilterBarVisibleChange = NOOP, drawerExpandedFilters = EMPTY_STRING_SET, onDrawerExpandedFiltersChange = NOOP, totalRows = 0, filteredRows = 0, sortRules = EMPTY_SORT_RULES, onSortRulesChange = NOOP, onAddSortRule = NOOP, onRemoveSortRule = NOOP, onToggleSortDir = NOOP, colOrder = EMPTY_COL_ORDER, onColOrderChange = NOOP, hiddenCols = EMPTY_STRING_SET, onToggleColVisibility = NOOP, onMoveCol = NOOP, groupBy = null, onGroupByChange = NOOP, primarySortKey, conditionalRules = EMPTY_CONDITIONAL_RULES, onAddConditionalRule = NOOP, onRemoveConditionalRule = NOOP, onUpdateConditionalRule = NOOP, filterFields = EMPTY_FILTER_FIELDS, currentView, onViewChange, supportedViewTypes, lifecycleTabLabel, fieldDefinitions = EMPTY_FIELD_DEFINITIONS, resolveColumnLabel: resolveColumnLabelProp, displayOptions = DEFAULT_DATA_LIST_DISPLAY_OPTIONS, onDisplayOptionsChange = NOOP, boardGroupByColumnOptions, renderFilterOptionValue, filterFieldContext, rowHeight = "default", onRowHeightChange = NOOP, isCreatingNewView, newViewName, onNewViewNameChange, onCreateNewView, onCancelNewView, creatingViewTypeLabel, viewName, onViewNameChange, showViewCounts, onShowViewCountsChange, }: TablePropertiesDrawerProps) { const [navPanel, setNavPanel] = React.useState("main") const sheetPanel: SheetPanel = !open ? "main" : initialPanel ? (initialPanel as SheetPanel) : navPanel const resolveColumnLabel = React.useCallback( (key: string) => resolveColumnLabelProp?.(key) ?? fieldDefinitions.find(f => f.key === key)?.label ?? key, [resolveColumnLabelProp, fieldDefinitions], ) const sortFieldList = React.useMemo( () => fieldDefinitions.filter( f => f.sortable !== false && f.key !== "select" && f.key !== "actions", ), [fieldDefinitions], ) const groupFieldList = React.useMemo( () => fieldDefinitions.filter(f => f.key !== "select" && f.key !== "actions"), [fieldDefinitions], ) const addableSortFields = React.useMemo(() => { const taken = new Set(sortRules.map(r => r.fieldKey)) return sortFieldList.filter(f => !taken.has(f.key)) }, [sortFieldList, sortRules]) const viewSurface = currentView ?? "table" const isBoardView = viewSurface === "board" const boardGroupByLabel = boardGroupByColumnOptions?.find(o => o.key === displayOptions.boardGroupByColumnKey)?.label const viewDisplayLabel = dataListViewLabel(viewSurface) const viewDisplayDesc = (() => { if (viewSurface === "board") { return [ boardGroupByLabel ? `By ${boardGroupByLabel}` : null, `${displayOptions.boardLineCount}-line`, displayOptions.showColumnLabels ? "Column labels" : "No labels", ] .filter(Boolean) .join(" · ") } if (viewSurface === "list") { return [ displayOptions.showColumnLabels ? "Column labels" : "No labels", displayOptions.showToolbarSearch ? "Toolbar search" : "No search", ].join(" · ") } if (viewSurface === "dashboard") { return "Charts · KPI metrics" } return [showGridlines ? "Gridlines" : null, pagination ? "Paginated" : null].filter(Boolean).join(" · ") || "Default" })() const viewDisplayIcon = DATA_LIST_VIEW_TILES.find(t => t.value === viewSurface)?.icon ?? "fa-table" // ── Sort drag-and-drop ──────────────────────────────────────────────────── const sortDrag = useDraggableList(sortRules, r => r.id, onSortRulesChange) // ── Columns drag-and-drop ───────────────────────────────────────────────── const orderable = colOrder.filter(k => k !== "select" && k !== "actions") const colDrag = useDraggableList( orderable, k => k, newOrder => onColOrderChange(["select", ...newOrder, "actions"]), ) // Current primary sort label for display in main panel const primarySortLabel = primarySortKey ? resolveColumnLabel(primarySortKey) : sortRules[0]?.fieldKey ? resolveColumnLabel(sortRules[0].fieldKey) : null const viewTypeTileOptions = React.useMemo( () => supportedViewTypes && supportedViewTypes.length > 0 ? dataListViewTilesForHub(supportedViewTypes).map(t => ({ value: t.type, label: t.label, icon: t.icon, })) : DATA_LIST_VIEW_TILES, [supportedViewTypes], ) // In creation mode, dismissal (Esc, Cancel) must route through // `onCancelNewView` so the parent resets the ephemeral creation state. // Falls back to `onOpenChange(false)` when no explicit cancel hook is wired. const handleSheetOpenChange = React.useCallback( (next: boolean) => { if (!next) { setNavPanel("main") if (isCreatingNewView && onCancelNewView) { onCancelNewView() return } } onOpenChange(next) }, [isCreatingNewView, onCancelNewView, onOpenChange], ) return ( { // Non-modal Properties sheet must not scroll the hub behind it when // Radix moves focus into the panel — but keyboard users still need // focus inside the sheet (column-header filter deep-link). event.preventDefault() const root = event.currentTarget as HTMLElement requestAnimationFrame(() => { const focusHost = root.querySelector("[data-properties-initial-focus]") ?? root const target = focusHost.querySelector( "input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled])", ) ?? focusHost.querySelector("button:not([disabled])") target?.focus() }) }} > setNavPanel("main")} backLabel="Back to Properties" tipClassName={PROPERTIES_SHEET_TOOLTIP_Z} tipZIndex={PROPERTIES_SHEET_TOOLTIP_Z_INDEX} /> {sheetPanel === "main" ? ( <> {(() => { const showEditNameInput = !isCreatingNewView && onViewNameChange !== undefined const subtitle = lifecycleTabLabel && !(showEditNameInput && lifecycleTabLabel === (viewName ?? "")) ? lifecycleTabLabel : null return ( ) })()} {/* Name field — shared shape across creation and edit modes. Creation: bound to `(newViewName, onNewViewNameChange)`, `autoFocus` so the user lands on the input ready to type. Edit: bound to `(viewName, onViewNameChange)`, NO `autoFocus` (the user opened Properties to look around — stealing focus would be hostile). If neither pair is supplied this block does not render. */} {isCreatingNewView && onNewViewNameChange ? (
onNewViewNameChange(e.target.value)} className="mt-1.5 h-9" placeholder="View name" autoFocus />
{onViewChange && currentView ? ( sectionLabel="View type" options={viewTypeTileOptions} columns={4} inlineVisibleCount={4} overflowMenuClassName={PROPERTIES_SHEET_PORTAL_Z} density="compact" value={currentView} onValueChange={onViewChange} interaction="button" idPrefix="props-view" labelPlacement="below" /> ) : null}
) : !isCreatingNewView && onViewNameChange ? (
onViewNameChange(e.target.value)} className="mt-1.5 h-9" placeholder="View name" />
{onViewChange && currentView ? ( sectionLabel="View type" options={viewTypeTileOptions} columns={4} inlineVisibleCount={4} overflowMenuClassName={PROPERTIES_SHEET_PORTAL_Z} density="compact" value={currentView} onValueChange={onViewChange} interaction="button" idPrefix="props-view" labelPlacement="below" /> ) : null}
) : onViewChange && currentView ? (
sectionLabel="View type" options={viewTypeTileOptions} columns={4} inlineVisibleCount={4} overflowMenuClassName={PROPERTIES_SHEET_PORTAL_Z} density="compact" value={currentView} onValueChange={onViewChange} interaction="button" idPrefix="props-view" labelPlacement="below" />
) : null} {/* Option list — inset rows + rounded hover (not edge-to-edge) */}
{([ { id: "table-display" as SheetPanel, icon: viewDisplayIcon, label: viewDisplayLabel, desc: viewDisplayDesc, }, { id: "filter" as SheetPanel, icon: "fa-filter", label: "Filter", desc: activeFilters.length === 0 ? `Showing all ${filteredRows} rows.` : `${activeFilters.length} filter${activeFilters.length !== 1 ? "s" : ""} active · ${filteredRows} rows.`, }, { id: "sort" as SheetPanel, icon: "fa-arrow-up-arrow-down", label: "Sort", desc: primarySortLabel ? `Sorted by ${primarySortLabel}.` : "Not sorted.", }, { id: "group" as SheetPanel, icon: "fa-layer-group", label: "Group", desc: groupBy ? `Grouped by ${resolveColumnLabel(groupBy)}.` : "No grouping.", }, { id: "columns" as SheetPanel, icon: "fa-table-columns", label: "Columns", desc: hiddenCols.size === 0 ? "All columns visible." : `${hiddenCols.size} column${hiddenCols.size !== 1 ? "s" : ""} hidden.`, }, { id: "conditional-rules" as SheetPanel, icon: "fa-palette", label: "Conditional rules", desc: conditionalRules.length === 0 ? "No rules applied." : `${conditionalRules.length} rule${conditionalRules.length !== 1 ? "s" : ""} active.`, }, ] as { id: SheetPanel; icon: string; label: string; desc: string }[]).map(item => (