diff --git a/.prototools b/.prototools index 665b4c67..e67f8808 100644 --- a/.prototools +++ b/.prototools @@ -1,5 +1,5 @@ bun = "1.3.4" -node = "~22" +node = "~24" [settings] auto-install = true diff --git a/apps/web/app/(docs)/docs/[[...slug]]/page.tsx b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx index 1e7d4e51..4a4a2043 100644 --- a/apps/web/app/(docs)/docs/[[...slug]]/page.tsx +++ b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx @@ -164,7 +164,7 @@ export default async function Page({
{metadata.summary}
- +
diff --git a/apps/web/app/examples/data-views/_/columns.ts b/apps/web/app/examples/data-views/_/columns.ts new file mode 100644 index 00000000..55efda80 --- /dev/null +++ b/apps/web/app/examples/data-views/_/columns.ts @@ -0,0 +1,85 @@ +import { createColumnBuilder } from '@bazza-ui/data-view/react' +import { + AlarmClockIcon, + CalendarIcon, + CircleDotIcon, + FlameIcon, + TagsIcon, + TextIcon, +} from 'lucide-react' +import { ISSUE_STATUSES, LABELS_BY_ID } from './data' +import type { Issue } from './types' + +const col = createColumnBuilder() + +export const columnsConfig = [ + col + .text() + .id('title') + .displayName('Title') + .icon(TextIcon) + .accessor((d) => d.title) + .sortable() + .build(), + + col + .option() + .id('status') + .displayName('Status') + .icon(CircleDotIcon) + .accessor((d) => d.status.id) + .sortable() + .options( + ISSUE_STATUSES.map((s) => ({ + label: s.name, + value: s.id, + icon: s.icon, + })), + ) + .build(), + + // Labels: inferred from data via transformValueToOptionFn + // No static .options() — the ColumnDataService scans all rows, + // deduplicates label ids, and maps each through this function. + col + .multiOption() + .id('labels') + .displayName('Labels') + .icon(TagsIcon) + .accessor((d) => d.labels?.map((l) => l.id) ?? []) + .transformValueToOptionFn((labelId) => { + const label = LABELS_BY_ID.get(labelId) + return { + label: label?.name ?? labelId, + value: labelId, + } + }) + .build(), + + col + .number() + .id('estimatedHours') + .displayName('Est. Hours') + .icon(AlarmClockIcon) + .accessor((d) => d.estimatedHours) + .sortable() + .build(), + + col + .date() + .id('startDate') + .displayName('Start Date') + .icon(CalendarIcon) + .accessor((d) => d.startDate as Date) + .sortable() + .build(), + + col + .boolean() + .id('isUrgent') + .displayName('Urgent') + .icon(FlameIcon) + .accessor((d) => d.isUrgent) + .toggledStateName('Urgent') + .build(), +] as const diff --git a/apps/web/app/examples/data-views/_/data.ts b/apps/web/app/examples/data-views/_/data.ts new file mode 100644 index 00000000..212f9f6c --- /dev/null +++ b/apps/web/app/examples/data-views/_/data.ts @@ -0,0 +1,154 @@ +import { sub } from 'date-fns' +import { + CircleCheckIcon, + CircleDashedIcon, + CircleDotIcon, + CircleIcon, +} from 'lucide-react' +import { nanoid } from 'nanoid' +import { randomInteger, sample } from 'remeda' +import type { Issue, IssueLabel, IssueStatus, User } from './types' + +// ── Static Reference Data ────────────────────────────────── + +export const USERS: User[] = [ + { id: 'u1', name: 'John Smith', picture: '/avatars/john-smith.png' }, + { id: 'u2', name: 'Rose Eve', picture: '/avatars/rose-eve.png' }, + { id: 'u3', name: 'Adam Young', picture: '/avatars/adam-young.png' }, + { id: 'u4', name: 'Michael Scott', picture: '/avatars/michael-scott.png' }, +] + +export const ISSUE_STATUSES: IssueStatus[] = [ + { id: 'backlog', name: 'Backlog', icon: CircleDashedIcon }, + { id: 'todo', name: 'Todo', icon: CircleIcon }, + { id: 'in-progress', name: 'In Progress', icon: CircleDotIcon }, + { id: 'done', name: 'Done', icon: CircleCheckIcon }, +] + +export const ISSUE_LABELS: IssueLabel[] = [ + { id: 'l1', name: 'Bug', color: 'red' }, + { id: 'l2', name: 'Enhancement', color: 'green' }, + { id: 'l3', name: 'Task', color: 'blue' }, + { id: 'l4', name: 'Urgent', color: 'pink' }, + { id: 'l5', name: 'Frontend', color: 'orange' }, + { id: 'l6', name: 'Backend', color: 'teal' }, + { id: 'l7', name: 'Performance', color: 'purple' }, + { id: 'l8', name: 'Documentation', color: 'amber' }, + { id: 'l9', name: 'Security', color: 'sky' }, + { id: 'l10', name: 'Testing', color: 'yellow' }, + { id: 'l11', name: 'Refactor', color: 'lime' }, + { id: 'l12', name: 'API', color: 'red' }, + { id: 'l13', name: 'Database', color: 'violet' }, + { id: 'l14', name: 'AI Model', color: 'cyan' }, + { id: 'l15', name: 'Infrastructure', color: 'emerald' }, + { id: 'l16', name: 'Accessibility', color: 'rose' }, + { id: 'l17', name: 'Monitoring', color: 'indigo' }, + { id: 'l18', name: 'Authentication', color: 'fuchsia' }, + { id: 'l19', name: 'Deployment', color: 'green' }, + { id: 'l20', name: 'Feature Request', color: 'orange' }, +] + +/** Lookup table for label id -> IssueLabel. */ +export const LABELS_BY_ID = new Map(ISSUE_LABELS.map((l) => [l.id, l])) + +// ── Issue Title Generator ────────────────────────────────── + +const VERBS = [ + 'Fix', + 'Add', + 'Improve', + 'Refactor', + 'Update', + 'Remove', + 'Implement', + 'Optimize', + 'Redesign', + 'Revert', +] + +const NOUNS = [ + 'task sidebar', + 'project view', + 'keyboard shortcuts', + 'user permissions', + 'search performance', + 'issue modal', + 'auth flow', + 'API integration', + 'activity feed', + 'notifications', + 'team management', + 'board drag & drop', + 'custom workflows', + 'mobile responsiveness', + 'comment threading', + 'GitHub sync', + 'dark mode', + 'date picker', + 'status badges', + 'workspace settings', +] + +const SUFFIXES = [ + 'in Safari', + 'for enterprise customers', + 'on slow connections', + 'edge case in Firefox', + 'when duplicating issues', + 'for archived projects', + 'in mobile view', + 'on user onboarding', + 'when using keyboard nav', + 'for SSO users', +] + +function pick(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]! +} + +function generateIssueTitle(): string { + const verb = pick(VERBS) + const noun = pick(NOUNS) + const suffix = Math.random() < 0.5 ? '' : ` ${pick(SUFFIXES)}` + return `${verb} ${noun}${suffix}` +} + +// ── Issue Generator ──────────────────────────────────────── + +function generateIssue(): Issue { + const status = pick(ISSUE_STATUSES) + const assignee = Math.random() > 0.3 ? pick(USERS) : undefined + const labelCount = randomInteger(0, 2) + const labels = + labelCount > 0 + ? (sample(ISSUE_LABELS, labelCount) as IssueLabel[]) + : undefined + const estimatedHours = randomInteger(1, 16) + const startDate = + status.id === 'backlog' + ? undefined + : sub(new Date(), { days: randomInteger(1, 90) }) + const isUrgent = Math.random() > 0.85 + + return { + id: nanoid(), + title: generateIssueTitle(), + status, + labels, + assignee, + estimatedHours, + startDate, + isUrgent, + } +} + +export function generateIssues(count: number): Issue[] { + const arr: Issue[] = [] + for (let i = 0; i < count; i++) { + arr.push(generateIssue()) + } + return arr +} + +/** Default dataset: 30k rows. */ +export const ISSUES = generateIssues(30_000) diff --git a/apps/web/app/examples/data-views/_/issues-table.tsx b/apps/web/app/examples/data-views/_/issues-table.tsx new file mode 100644 index 00000000..474b66ed --- /dev/null +++ b/apps/web/app/examples/data-views/_/issues-table.tsx @@ -0,0 +1,1020 @@ +'use client' + +import { + type Column, + type DataViewState, + type FiltersState, + type SortRule, + useDataView, +} from '@bazza-ui/data-view/react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { format } from 'date-fns' +import { + ArrowDownIcon, + ArrowUpDownIcon, + ArrowUpIcon, + BookmarkIcon, + CheckIcon, + CopyIcon, + EllipsisIcon, + FilterIcon, + FlameIcon, + PencilIcon, + PlusIcon, + SaveIcon, + Trash2Icon, + XIcon, +} from 'lucide-react' +import { useCallback, useMemo, useRef, useState } from 'react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Separator } from '@/components/ui/separator' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' + +import { cn } from '@/lib/utils' +import { FilterActions } from '@/registry/ui/data-view/components/actions/filter-actions' +import { FilterItem } from '@/registry/ui/data-view/components/item/filter-item' +import { FilterOperator } from '@/registry/ui/data-view/components/item/filter-operator' +import { FilterRemove } from '@/registry/ui/data-view/components/item/filter-remove' +import { FilterSubject } from '@/registry/ui/data-view/components/item/filter-subject' +import { FilterValue } from '@/registry/ui/data-view/components/item/filter-value' +import { FilterList } from '@/registry/ui/data-view/components/list/filter-list' +import { FilterMenu } from '@/registry/ui/data-view/components/menu/filter-menu' +import { DataViewProvider } from '@/registry/ui/data-view/components/provider/data-view-provider' +import { columnsConfig } from './columns' +import { ISSUES } from './data' +// Ensure module augmentation for DataViewStateMeta is loaded +import type { Issue } from './types' +import { DEFAULT_VIEW, PRESET_VIEWS } from './views' + +// ── Helpers ──────────────────────────────────────────────── + +function findActiveView( + views: DataViewState[], + baseView: { filters: FiltersState; sort: SortRule[]; id?: string }, +): DataViewState | undefined { + // Match by id first, then by state deep-equal + return ( + views.find((v) => v.id === baseView.id) ?? + views.find( + (v) => + JSON.stringify(v.filters) === JSON.stringify(baseView.filters) && + JSON.stringify(v.sort) === JSON.stringify(baseView.sort), + ) + ) +} + +// ── ViewSwitcher ─────────────────────────────────────────── + +function ViewSwitcher({ + views, + activeView, + hasOverrides, + onLoadView, + onSaveView, +}: { + views: DataViewState[] + activeView: DataViewState | undefined + hasOverrides: boolean + onLoadView: (view: DataViewState) => void + onSaveView: () => void +}) { + const presets = views.filter((v) => v.meta?.isPreset) + const custom = views.filter((v) => !v.meta?.isPreset) + + return ( +
+
+ {presets.map((view) => { + const isActive = activeView?.id === view.id + return ( + + ) + })} +
+ + {custom.length > 0 && ( + <> + +
+ {custom.map((view) => { + const isActive = activeView?.id === view.id + return ( + + ) + })} +
+ + )} + + {hasOverrides && ( + + (filtered) + + )} + + + + + + Save current view + +
+ ) +} + +// ── ViewHeader (Linear-style) ────────────────────────────── + +function ViewHeader({ + activeView, + hasOverrides, + onEdit, + onDuplicate, + onDelete, +}: { + activeView: DataViewState | undefined + hasOverrides: boolean + onEdit: () => void + onDuplicate: () => void + onDelete: () => void +}) { + if (!activeView) return null + + return ( +
+

+ {activeView.name} +

+ {hasOverrides && ( + (modified) + )} + + + + + + + + + Edit view + + + + Duplicate + + + + + Delete view + + + + + {activeView.meta?.description && !hasOverrides && ( + <> + + + {activeView.meta.description} + + + )} +
+ ) +} + +// ── ViewEditor (Linear-style inline edit panel) ──────────── + +function ViewEditor({ + view, + columns, + onSave, + onCancel, +}: { + view: DataViewState + columns: Column[] + onSave: (update: { + name: string + description: string + filters: FiltersState + sort: DataViewState['sort'] + }) => void + onCancel: () => void +}) { + const [name, setName] = useState(view.name ?? '') + const [description, setDescription] = useState(view.meta?.description ?? '') + const [editFilters, setEditFilters] = useState([ + ...view.filters, + ]) + const [editSort, setEditSort] = useState([ + ...view.sort, + ]) + const [filterPickerOpen, setFilterPickerOpen] = useState(false) + const [selectedColumnId, setSelectedColumnId] = useState(null) + + const filterableColumns = columns.filter( + (c) => + c.type === 'option' || c.type === 'multiOption' || c.type === 'boolean', + ) + + const selectedColumn = selectedColumnId + ? columns.find((c) => c.id === selectedColumnId) + : null + + function handleAddFilterValue(column: Column, value: string) { + if (column.type === 'boolean') { + setEditFilters((prev) => { + const without = prev.filter((f) => f.columnId !== column.id) + return [ + ...without, + { + columnId: column.id, + type: column.type, + operator: 'is', + values: [value === 'true'], + }, + ] + }) + setSelectedColumnId(null) + setFilterPickerOpen(false) + return + } + + setEditFilters((prev) => { + const existing = prev.find((f) => f.columnId === column.id) + if (existing) { + if (existing.values.includes(value)) { + // Remove value + const newValues = existing.values.filter((v) => v !== value) + if (newValues.length === 0) { + return prev.filter((f) => f.columnId !== column.id) + } + return prev.map((f) => + f.columnId === column.id ? { ...f, values: newValues } : f, + ) + } + // Add value + return prev.map((f) => + f.columnId === column.id ? { ...f, values: [...f.values, value] } : f, + ) + } + // New filter + return [ + ...prev, + { + columnId: column.id, + type: column.type, + operator: column.type === 'multiOption' ? 'include' : 'is any of', + values: [value], + }, + ] + }) + } + + function handleRemoveEditFilter(columnId: string) { + setEditFilters((prev) => prev.filter((f) => f.columnId !== columnId)) + } + + function handleRemoveEditSort(index: number) { + setEditSort((prev) => prev.filter((_, i) => i !== index)) + } + + const isDirty = + name !== (view.name ?? '') || + description !== (view.meta?.description ?? '') || + JSON.stringify(editFilters) !== JSON.stringify(view.filters) || + JSON.stringify(editSort) !== JSON.stringify(view.sort) + + return ( +
+ {/* Name + actions */} +
+
+ setName(e.target.value)} + placeholder="View name" + className="text-lg font-semibold h-10 bg-background" + /> + setDescription(e.target.value)} + placeholder="Add a description..." + className="text-sm text-muted-foreground h-8 bg-background" + /> +
+
+ + +
+
+ + {/* Editable filter chips */} +
+ {editFilters.map((filter) => { + const col = columns.find((c) => c.id === filter.columnId) + if (!col) return null + const Icon = col.icon as + | React.ComponentType<{ className?: string }> + | undefined + + return ( + + {Icon && } + {col.displayName} + {filter.operator} + + + ) + })} + + {editSort.map((rule, i) => { + if (rule.type !== 'column') return null + const col = columns.find((c) => c.id === rule.columnId) + if (!col) return null + return ( + + {rule.direction === 'asc' ? ( + + ) : ( + + )} + {col.displayName} + + + ) + })} + + {/* Add filter picker */} + + + + + + {!selectedColumn ? ( + + + + No columns found. + + {filterableColumns.map((col) => { + const Icon = col.icon as React.ComponentType<{ + className?: string + }> + return ( + setSelectedColumnId(col.id)} + > + {Icon && ( + + )} + {col.displayName} + + ) + })} + + + + ) : ( + + + + No options found. + + {selectedColumn.type === 'boolean' ? ( + <> + {[ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ].map((opt) => { + const existingFilter = editFilters.find( + (f) => f.columnId === selectedColumn.id, + ) + const isSelected = + existingFilter?.values[0] === (opt.value === 'true') + return ( + + handleAddFilterValue(selectedColumn, opt.value) + } + > +
+ {isSelected && } +
+ {opt.label} +
+ ) + })} + + ) : ( + selectedColumn.getOptions().map((opt) => { + const existingFilter = editFilters.find( + (f) => f.columnId === selectedColumn.id, + ) + const isSelected = + existingFilter?.values.includes(opt.value) ?? false + const Icon = opt.icon as + | React.ComponentType<{ className?: string }> + | undefined + + return ( + + handleAddFilterValue(selectedColumn, opt.value) + } + > +
+ {isSelected && } +
+ {Icon && ( + + )} + {opt.label} +
+ ) + }) + )} +
+ + + setSelectedColumnId(null)} + className="justify-center text-muted-foreground" + > + Back to columns + + +
+
+ )} +
+
+
+
+ ) +} + +// ── VirtualizedTable ─────────────────────────────────────── + +const ROW_HEIGHT = 41 // px — matches the default table row height + +function VirtualizedTable({ + data, + columns, + parentRef, +}: { + data: Issue[] + columns: Column[] + parentRef: React.RefObject +}) { + const virtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + }) + + return ( +
+ + + + ID + + c.id === 'title')!} /> + + + c.id === 'status')!} + /> + + Labels + + c.id === 'estimatedHours')!} + /> + + + c.id === 'startDate')!} + /> + + Urgent + + + + {data.length === 0 ? ( + + +
+ + No issues match your filters. +
+
+
+ ) : ( + <> + {/* Top spacer */} + {(virtualizer.getVirtualItems()[0]?.start ?? 0) > 0 && ( +
+ + )} + {virtualizer.getVirtualItems().map((virtualRow) => { + const issue = data[virtualRow.index]! + const StatusIcon = issue.status.icon + const labels = issue.labels + return ( + + + {issue.id.slice(0, 8)} + + {issue.title} + +
+ + {issue.status.name} +
+
+ +
+ {labels && labels.length > 0 ? ( + labels.map((label) => ( + + {label.name} + + )) + ) : ( + + -- + + )} +
+
+ + {issue.estimatedHours}h + + + {issue.startDate ? ( + format(issue.startDate, 'MMM d, yyyy') + ) : ( + -- + )} + + + {issue.isUrgent && ( + + )} + +
+ ) + })} + {/* Bottom spacer */} + {virtualizer.getVirtualItems().length > 0 && ( + + + )} + + )} + +
+
+
+
+ ) +} + +// ── SortableHeader ───────────────────────────────────────── + +function SortableHeader({ column }: { column: Column }) { + const sortDir = column.getIsSorted() + + if (!column.sortable) { + return {column.displayName} + } + + return ( + + ) +} + +// ── Main Component ───────────────────────────────────────── + +export function IssuesTable() { + const [views, setViews] = useState(PRESET_VIEWS) + const [editingViewId, setEditingViewId] = useState(null) + const tableContainerRef = useRef(null) + + const dataView = useDataView({ + strategy: 'client', + data: ISSUES, + columnsConfig, + defaultBaseView: DEFAULT_VIEW, + entityName: 'issues', + }) + + const { columns, baseView, overrides, sort, processedData, snapshot } = + dataView + + const hasOverrides = overrides.filters.length > 0 || overrides.sort.length > 0 + + const activeView = useMemo( + () => findActiveView(views, baseView), + [views, baseView], + ) + + const editingView = editingViewId + ? views.find((v) => v.id === editingViewId) + : null + + // ── View CRUD ────────────────────────────────────────── + + const handleLoadView = useCallback( + (view: DataViewState) => { + setEditingViewId(null) + baseView.load(view) + }, + [baseView], + ) + + const handleSaveNewView = useCallback(() => { + const id = `custom-${Date.now()}` + const snap = snapshot({ + id, + name: `Saved View ${views.filter((v) => !v.meta?.isPreset).length + 1}`, + }) + const newView: DataViewState = { + id, + name: snap.name ?? 'Untitled View', + filters: snap.filters, + sort: snap.sort, + meta: { description: '', isPreset: false }, + } + setViews((prev) => [...prev, newView]) + baseView.load(newView) + setEditingViewId(id) + }, [views, snapshot, baseView]) + + const handleEditView = useCallback(() => { + if (activeView && !activeView.meta?.isPreset) { + setEditingViewId(activeView.id ?? null) + } + }, [activeView]) + + const handleSaveEdit = useCallback( + (update: { + name: string + description: string + filters: FiltersState + sort: DataViewState['sort'] + }) => { + if (!editingViewId) return + + setViews((prev) => + prev.map((v) => + v.id === editingViewId + ? { + ...v, + name: update.name, + filters: update.filters, + sort: update.sort, + meta: { + ...v.meta, + description: update.description || undefined, + }, + } + : v, + ), + ) + + // Also update the live base view to reflect edits + baseView.load({ + id: editingViewId, + name: update.name, + filters: update.filters, + sort: update.sort, + }) + + setEditingViewId(null) + }, + [editingViewId, baseView], + ) + + const handleCancelEdit = useCallback(() => { + setEditingViewId(null) + }, []) + + const handleDuplicateView = useCallback(() => { + if (!activeView) return + + const id = `custom-${Date.now()}` + const duplicate: DataViewState = { + id, + name: `Copy of ${activeView.name}`, + filters: [...activeView.filters], + sort: [...activeView.sort], + meta: { + description: activeView.meta?.description, + isPreset: false, + }, + } + setViews((prev) => [...prev, duplicate]) + baseView.load(duplicate) + setEditingViewId(id) + }, [activeView, baseView]) + + const handleDeleteView = useCallback(() => { + if (!activeView || activeView.meta?.isPreset) return + + setViews((prev) => prev.filter((v) => v.id !== activeView.id)) + setEditingViewId(null) + + // Fall back to the first preset view + const fallback = PRESET_VIEWS[0]! + baseView.load(fallback) + }, [activeView, baseView]) + + // ── Render ────────────────────────────────────────────── + + return ( + + +
+ {/* View Switcher (tab bar) */} + + + {/* View Header (name + ... menu) */} + + + {/* Inline View Editor (Linear-style) */} + {editingView && ( + + )} + + {/* Override Toolbar — FilterMenu + FilterList + FilterActions */} +
+
+ + + {({ filter, column }) => ( + + + + + + + )} + + +
+
+ + {processedData.length} of {ISSUES.length} issues + + {sort.length > 0 && ( + + )} +
+
+ + {/* Virtualized Table */} + + + {/* Debug info */} +
+ + View state (debug) + +
+              {JSON.stringify(
+                {
+                  activeView: activeView
+                    ? {
+                        id: activeView.id,
+                        name: activeView.name,
+                        description: activeView.meta?.description,
+                        isPreset: activeView.meta?.isPreset,
+                      }
+                    : null,
+                  baseView: {
+                    id: baseView.id,
+                    name: baseView.name,
+                    filters: baseView.filters,
+                    sort: baseView.sort,
+                  },
+                  overrides: {
+                    filters: overrides.filters,
+                    sort: overrides.sort,
+                  },
+                  effective: dataView.view,
+                  savedViews: views
+                    .filter((v) => !v.meta?.isPreset)
+                    .map((v) => ({ id: v.id, name: v.name })),
+                },
+                null,
+                2,
+              )}
+            
+
+
+
+
+ ) +} diff --git a/apps/web/app/examples/data-views/_/types.ts b/apps/web/app/examples/data-views/_/types.ts new file mode 100644 index 00000000..1e5c8969 --- /dev/null +++ b/apps/web/app/examples/data-views/_/types.ts @@ -0,0 +1,46 @@ +import type { LucideIcon } from 'lucide-react' + +// ── Module augmentation ──────────────────────────────────── +// Extends DataViewState.meta with app-specific fields so that +// a DataViewState *is* a saved view — no wrapper type needed. + +declare module '@bazza-ui/data-view' { + interface DataViewStateMeta { + /** Optional description of what this view shows. */ + description?: string + /** Whether this is a built-in preset (cannot be edited or deleted). */ + isPreset?: boolean + } +} + +// ── Domain Types ─────────────────────────────────────────── + +export type Issue = { + id: string + title: string + description?: string + status: IssueStatus + labels?: IssueLabel[] + assignee?: User + estimatedHours: number + startDate?: Date + isUrgent: boolean +} + +export type User = { + id: string + name: string + picture: string +} + +export type IssueLabel = { + id: string + name: string + color: string +} + +export type IssueStatus = { + id: 'backlog' | 'todo' | 'in-progress' | 'done' + name: string + icon: LucideIcon +} diff --git a/apps/web/app/examples/data-views/_/views.ts b/apps/web/app/examples/data-views/_/views.ts new file mode 100644 index 00000000..1d4cec6c --- /dev/null +++ b/apps/web/app/examples/data-views/_/views.ts @@ -0,0 +1,111 @@ +import type { DataViewState } from '@bazza-ui/data-view/react' + +// Ensure the module augmentation in ./types is loaded +import type {} from './types' + +/** + * Preset views — built-in views that cannot be edited or deleted. + * Each is a DataViewState with `meta.isPreset: true`. + */ +export const PRESET_VIEWS: DataViewState[] = [ + { + id: 'all', + name: 'All Issues', + filters: [], + sort: [], + meta: { + description: 'Every issue in the project, unfiltered.', + isPreset: true, + }, + }, + { + id: 'bugs', + name: 'Bugs', + filters: [ + { + columnId: 'labels', + type: 'multiOption', + operator: 'include', + values: ['l1'], + }, + ], + sort: [{ type: 'column', columnId: 'estimatedHours', direction: 'desc' }], + meta: { + description: + 'Issues tagged with the Bug label, sorted by estimated hours.', + isPreset: true, + }, + }, + { + id: 'in-progress', + name: 'In Progress', + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['in-progress'], + }, + ], + sort: [{ type: 'column', columnId: 'startDate', direction: 'desc' }], + meta: { + description: + 'Issues currently being worked on, most recently started first.', + isPreset: true, + }, + }, + { + id: 'urgent', + name: 'Urgent', + filters: [ + { + columnId: 'isUrgent', + type: 'boolean', + operator: 'is', + values: [true], + }, + ], + sort: [{ type: 'column', columnId: 'estimatedHours', direction: 'asc' }], + meta: { + description: 'Urgent issues sorted by lowest estimated hours first.', + isPreset: true, + }, + }, + { + id: 'backlog', + name: 'Backlog', + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['backlog'], + }, + ], + sort: [{ type: 'column', columnId: 'title', direction: 'asc' }], + meta: { + description: 'Backlog issues sorted alphabetically by title.', + isPreset: true, + }, + }, + { + id: 'frontend', + name: 'Frontend', + filters: [ + { + columnId: 'labels', + type: 'multiOption', + operator: 'include', + values: ['l5'], + }, + ], + sort: [{ type: 'column', columnId: 'status', direction: 'asc' }], + meta: { + description: 'Issues tagged with the Frontend label, sorted by status.', + isPreset: true, + }, + }, +] + +/** The default view to load initially. */ +export const DEFAULT_VIEW = PRESET_VIEWS[0]! diff --git a/apps/web/app/examples/data-views/layout.tsx b/apps/web/app/examples/data-views/layout.tsx new file mode 100644 index 00000000..c9a0e0bf --- /dev/null +++ b/apps/web/app/examples/data-views/layout.tsx @@ -0,0 +1,3 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/apps/web/app/examples/data-views/page.tsx b/apps/web/app/examples/data-views/page.tsx new file mode 100644 index 00000000..6f993464 --- /dev/null +++ b/apps/web/app/examples/data-views/page.tsx @@ -0,0 +1,36 @@ +'use client' + +import { NavBar } from '@/components/nav-bar' +import { IssuesTable } from './_/issues-table' + +export default function DataViewsExamplePage() { + return ( +
+
+
+ +
+
+
+
+
+
+

+ Data View{' '} + (Standalone) +

+

+ Client-side filtering and sorting powered by{' '} + + @bazza-ui/data-view + {' '} + — no TanStack Table, no external filter UI. +

+
+ +
+
+
+
+ ) +} diff --git a/apps/web/app/examples/data-views/server/_/actions.ts b/apps/web/app/examples/data-views/server/_/actions.ts new file mode 100644 index 00000000..61a8860e --- /dev/null +++ b/apps/web/app/examples/data-views/server/_/actions.ts @@ -0,0 +1,293 @@ +'use server' + +import { createColumnBuilder } from '@bazza-ui/data-view' +import { applyDataView } from '@bazza-ui/data-view/drizzle/pg' +import type { DataViewState } from '@bazza-ui/data-view/react' +import { eq, ilike, inArray, sql } from 'drizzle-orm' +import { db, schema } from '@/lib/db' +import type { Issue, IssueLabel, IssueStatus, User } from './types' + +// ── Server-only column config ──────────────────────────────── +// Minimal column definitions for the Drizzle adapter. Only id, type, +// and field mapping are needed — the adapter never renders options or icons. + +const col = createColumnBuilder() + +const noop = () => undefined as any +const serverColumnsConfig = [ + col + .text() + .id('title') + .displayName('Title') + .accessor(noop) + .field('title') + .build(), + col + .option() + .id('status') + .displayName('Status') + .accessor(noop) + .field('status.id') + .build(), + col + .option() + .id('assignee') + .displayName('Assignee') + .accessor(noop) + .field('assignee.id') + .build(), + col + .multiOption() + .id('labels') + .displayName('Labels') + .accessor(noop) + .field('labels.id') + .build(), + col + .number() + .id('estimatedHours') + .displayName('Est. Hours') + .accessor(noop) + .field('estimated_hours') + .build(), + col + .date() + .id('startDate') + .displayName('Start Date') + .accessor(noop) + .field('start_date') + .build(), + col + .boolean() + .id('isUrgent') + .displayName('Urgent') + .accessor(noop) + .field('is_urgent') + .build(), +] as const + +// ── Types ──────────────────────────────────────────────────── + +type FetchIssuesInput = { + view: DataViewState + page: number + pageSize: number + search?: string +} + +type FetchIssuesResult = { + data: Issue[] + totalCount: number + page: number + pageSize: number +} + +// ── Server Action ──────────────────────────────────────────── + +export async function fetchIssues( + input: FetchIssuesInput, +): Promise { + const { view, page, pageSize, search } = input + + console.log( + '[fetchIssues] input:', + JSON.stringify({ view, page, pageSize, search }, null, 2), + ) + + // Quick sanity check: count all issues in the DB + const [sanityCount] = await db + .select({ count: sql`count(*)` }) + .from(schema.issues) + console.log('[fetchIssues] total issues in DB:', sanityCount?.count) + + let result: { data: unknown[]; totalCount: number } + + try { + result = await applyDataView(db as any, { + table: schema.issues, + columns: serverColumnsConfig, + view, + relations: { + status: schema.statuses, + assignee: schema.users, + labels: { through: schema.issueLabels, to: schema.labels }, + }, + pagination: { + kind: 'offset', + page, + pageSize, + }, + search: search + ? { + query: search, + columns: ['title'], + mode: 'contains', + } + : undefined, + }) + } catch (err) { + console.error('[fetchIssues] applyDataView error:', err) + return { data: [], totalCount: 0, page, pageSize } + } + + console.log('[fetchIssues] result:', result) + + console.log('[fetchIssues] applyDataView result:', { + dataLength: result.data.length, + totalCount: result.totalCount, + firstRow: result.data[0], + }) + + const rawResult = result.data as any[] + + if (rawResult.length === 0) { + console.log( + '[fetchIssues] no rows returned, totalCount:', + result.totalCount, + ) + return { data: [], totalCount: result.totalCount, page, pageSize } + } + + // Inspect raw row shape + console.log('[fetchIssues] raw row keys:', Object.keys(rawResult[0])) + console.log( + '[fetchIssues] raw row sample:', + JSON.stringify(rawResult[0], null, 2), + ) + + // Normalize row shape: when applyDataView adds JOINs, Drizzle returns nested objects + // like { issues: { id, title, ... }, statuses: { id, name, ... } } + // Without JOINs, rows are flat: { id, title, statusId, ... } + const rawRows = rawResult.map((row) => { + // If the row has a nested 'issues' key, it's a joined result + if (row.issues && typeof row.issues === 'object') { + return row.issues + } + return row + }) + + console.log('[fetchIssues] normalized row keys:', Object.keys(rawRows[0])) + + // Step 2: Hydrate the results with related data + // Note: Drizzle returns JS property names (camelCase), not DB column names (snake_case) + const issueIds = rawRows.map((r: any) => r.id as string) + const statusIds = [...new Set(rawRows.map((r: any) => r.statusId as string))] + const assigneeIds = [ + ...new Set( + rawRows.map((r: any) => r.assigneeId as string | null).filter(Boolean), + ), + ] as string[] + + const [statusRows, userRows, issueLabelRows] = await Promise.all([ + db + .select() + .from(schema.statuses) + .where(inArray(schema.statuses.id, statusIds)), + assigneeIds.length > 0 + ? db + .select() + .from(schema.users) + .where(inArray(schema.users.id, assigneeIds)) + : Promise.resolve([]), + db + .select({ + issueId: schema.issueLabels.issueId, + labelId: schema.issueLabels.labelId, + labelName: schema.labels.name, + labelColor: schema.labels.color, + }) + .from(schema.issueLabels) + .innerJoin( + schema.labels, + eq(schema.labels.id, schema.issueLabels.labelId), + ) + .where(inArray(schema.issueLabels.issueId, issueIds)), + ]) + + console.log('[fetchIssues] hydration:', { + statuses: statusRows.length, + users: userRows.length, + issueLabels: issueLabelRows.length, + }) + + // Build lookup maps + const statusMap = new Map( + statusRows.map((s) => [s.id, { id: s.id, name: s.name, order: s.order }]), + ) + + const userMap = new Map( + userRows.map((u) => [ + u.id, + { id: u.id, name: u.name, email: u.email, picture: u.picture }, + ]), + ) + + const labelsByIssueId = new Map() + for (const row of issueLabelRows) { + const existing = labelsByIssueId.get(row.issueId) ?? [] + existing.push({ + id: row.labelId, + name: row.labelName, + color: row.labelColor, + }) + labelsByIssueId.set(row.issueId, existing) + } + + // Step 3: Assemble hydrated Issue objects + // Drizzle uses JS names: statusId, assigneeId, estimatedHours, startDate, isUrgent, createdAt + const data: Issue[] = rawRows.map((row) => ({ + id: row.id, + title: row.title, + description: row.description, + status: statusMap.get(row.statusId) ?? { + id: row.statusId, + name: 'Unknown', + order: 0, + }, + assignee: row.assigneeId ? (userMap.get(row.assigneeId) ?? null) : null, + labels: labelsByIssueId.get(row.id) ?? [], + priority: row.priority, + estimatedHours: row.estimatedHours, + startDate: row.startDate, + isUrgent: row.isUrgent, + createdAt: row.createdAt, + })) + + console.log('[fetchIssues] returning', data.length, 'hydrated issues') + + return { + data, + totalCount: result.totalCount, + page, + pageSize, + } +} + +// ── Option-fetching server actions ─────────────────────────── +// Called by the client via React Query to populate async filter options. + +export async function fetchStatuses(): Promise<{ id: string; name: string }[]> { + return db + .select({ id: schema.statuses.id, name: schema.statuses.name }) + .from(schema.statuses) +} + +export async function fetchUsers(): Promise<{ id: string; name: string }[]> { + return db + .select({ id: schema.users.id, name: schema.users.name }) + .from(schema.users) +} + +export async function fetchLabels( + query?: string, +): Promise<{ id: string; name: string }[]> { + const base = db + .select({ id: schema.labels.id, name: schema.labels.name }) + .from(schema.labels) + + if (query) { + return base.where(ilike(schema.labels.name, `%${query}%`)) + } + + return base +} diff --git a/apps/web/app/examples/data-views/server/_/columns.ts b/apps/web/app/examples/data-views/server/_/columns.ts new file mode 100644 index 00000000..9bbe5b88 --- /dev/null +++ b/apps/web/app/examples/data-views/server/_/columns.ts @@ -0,0 +1,83 @@ +import { createColumnBuilder } from '@bazza-ui/data-view' +import { + AlarmClockIcon, + CalendarIcon, + CircleDotIcon, + FlameIcon, + TagsIcon, + TextIcon, + UserIcon, +} from 'lucide-react' +import type { Issue } from './types' + +const col = createColumnBuilder() + +export const columnsConfig = [ + col + .text() + .id('title') + .displayName('Title') + .icon(TextIcon) + .accessor((d) => d.title) + .sortable() + .field('title') + .build(), + + col + .option() + .id('status') + .displayName('Status') + .icon(CircleDotIcon) + .accessor((d) => d.status.id) + .sortable() + .field('status.id') + .build(), + + col + .option() + .id('assignee') + .displayName('Assignee') + .icon(UserIcon) + .accessor((d) => d.assignee?.id ?? '') + .field('assignee.id') + .build(), + + col + .multiOption() + .id('labels') + .displayName('Labels') + .icon(TagsIcon) + .accessor((d) => d.labels?.map((l) => l.id) ?? []) + .field('labels.id') + .build(), + + col + .number() + .id('estimatedHours') + .displayName('Est. Hours') + .icon(AlarmClockIcon) + .accessor((d) => d.estimatedHours) + .sortable() + .field('estimated_hours') + .build(), + + col + .date() + .id('startDate') + .displayName('Start Date') + .icon(CalendarIcon) + .accessor((d) => (d.startDate ? new Date(d.startDate) : undefined) as Date) + .sortable() + .field('start_date') + .build(), + + col + .boolean() + .id('isUrgent') + .displayName('Urgent') + .icon(FlameIcon) + .accessor((d) => d.isUrgent) + .toggledStateName('Urgent') + .field('is_urgent') + .build(), +] as const diff --git a/apps/web/app/examples/data-views/server/_/data.ts b/apps/web/app/examples/data-views/server/_/data.ts new file mode 100644 index 00000000..2f3dfb5f --- /dev/null +++ b/apps/web/app/examples/data-views/server/_/data.ts @@ -0,0 +1,59 @@ +import type { LucideIcon } from 'lucide-react' +import { + CircleCheckIcon, + CircleDashedIcon, + CircleDotIcon, + CircleIcon, +} from 'lucide-react' + +// ── Status icon mapping (client-side only, icons aren't serializable) ── + +export const STATUS_ICON_MAP: Record = { + backlog: CircleDashedIcon, + todo: CircleIcon, + 'in-progress': CircleDotIcon, + done: CircleCheckIcon, +} + +// ── Static option lists for the column builder (known ahead of time) ── + +export const ISSUE_STATUSES = [ + { id: 'backlog', name: 'Backlog' }, + { id: 'todo', name: 'Todo' }, + { id: 'in-progress', name: 'In Progress' }, + { id: 'done', name: 'Done' }, +] as const + +export const USERS = [ + { id: 'u1', name: 'John Smith' }, + { id: 'u2', name: 'Rose Eve' }, + { id: 'u3', name: 'Adam Young' }, + { id: 'u4', name: 'Michael Scott' }, +] as const + +export const ISSUE_LABELS = [ + { id: 'l1', name: 'Bug', color: 'red' }, + { id: 'l2', name: 'Enhancement', color: 'green' }, + { id: 'l3', name: 'Task', color: 'blue' }, + { id: 'l4', name: 'Urgent', color: 'pink' }, + { id: 'l5', name: 'Frontend', color: 'orange' }, + { id: 'l6', name: 'Backend', color: 'teal' }, + { id: 'l7', name: 'Performance', color: 'purple' }, + { id: 'l8', name: 'Documentation', color: 'amber' }, + { id: 'l9', name: 'Security', color: 'sky' }, + { id: 'l10', name: 'Testing', color: 'yellow' }, + { id: 'l11', name: 'Refactor', color: 'lime' }, + { id: 'l12', name: 'API', color: 'red' }, + { id: 'l13', name: 'Database', color: 'violet' }, + { id: 'l14', name: 'AI Model', color: 'cyan' }, + { id: 'l15', name: 'Infrastructure', color: 'emerald' }, + { id: 'l16', name: 'Accessibility', color: 'rose' }, + { id: 'l17', name: 'Monitoring', color: 'indigo' }, + { id: 'l18', name: 'Authentication', color: 'fuchsia' }, + { id: 'l19', name: 'Deployment', color: 'green' }, + { id: 'l20', name: 'Feature Request', color: 'orange' }, +] as const + +export const LABELS_BY_ID = new Map( + ISSUE_LABELS.map((l) => [l.id as string, l] as const), +) diff --git a/apps/web/app/examples/data-views/server/_/issues-table.tsx b/apps/web/app/examples/data-views/server/_/issues-table.tsx new file mode 100644 index 00000000..474539b7 --- /dev/null +++ b/apps/web/app/examples/data-views/server/_/issues-table.tsx @@ -0,0 +1,707 @@ +'use client' + +import { + type Column, + type DataViewState, + type FiltersState, + type SortRule, + useDataView, +} from '@bazza-ui/data-view/react' +import { useQuery } from '@tanstack/react-query' +import { format } from 'date-fns' +import { + ArrowDownIcon, + ArrowUpDownIcon, + ArrowUpIcon, + BookmarkIcon, + ChevronLeftIcon, + ChevronRightIcon, + CopyIcon, + EllipsisIcon, + FilterIcon, + FlameIcon, + Loader2Icon, + PencilIcon, + SaveIcon, + ServerIcon, + Trash2Icon, +} from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Separator } from '@/components/ui/separator' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' + +import { cn } from '@/lib/utils' +import { FilterActions } from '@/registry/ui/data-view/components/actions/filter-actions' +import { FilterItem } from '@/registry/ui/data-view/components/item/filter-item' +import { FilterOperator } from '@/registry/ui/data-view/components/item/filter-operator' +import { FilterRemove } from '@/registry/ui/data-view/components/item/filter-remove' +import { FilterSubject } from '@/registry/ui/data-view/components/item/filter-subject' +import { FilterValue } from '@/registry/ui/data-view/components/item/filter-value' +import { FilterList } from '@/registry/ui/data-view/components/list/filter-list' +import { FilterMenu } from '@/registry/ui/data-view/components/menu/filter-menu' +import { DataViewProvider } from '@/registry/ui/data-view/components/provider/data-view-provider' + +import { fetchIssues, fetchLabels, fetchStatuses, fetchUsers } from './actions' +import { columnsConfig } from './columns' +import { STATUS_ICON_MAP } from './data' +import type { Issue } from './types' +import { DEFAULT_VIEW, PRESET_VIEWS } from './views' + +// ── Helpers ──────────────────────────────────────────────── + +function findActiveView( + views: DataViewState[], + baseView: { filters: FiltersState; sort: SortRule[]; id?: string }, +): DataViewState | undefined { + return ( + views.find((v) => v.id === baseView.id) ?? + views.find( + (v) => + JSON.stringify(v.filters) === JSON.stringify(baseView.filters) && + JSON.stringify(v.sort) === JSON.stringify(baseView.sort), + ) + ) +} + +const PAGE_SIZE = 25 + +// ── ViewSwitcher ─────────────────────────────────────────── + +function ViewSwitcher({ + views, + activeView, + hasOverrides, + onLoadView, + onSaveView, +}: { + views: DataViewState[] + activeView: DataViewState | undefined + hasOverrides: boolean + onLoadView: (view: DataViewState) => void + onSaveView: () => void +}) { + const presets = views.filter((v) => v.meta?.isPreset) + const custom = views.filter((v) => !v.meta?.isPreset) + + return ( +
+
+ {presets.map((view) => { + const isActive = activeView?.id === view.id + return ( + + ) + })} +
+ + {custom.length > 0 && ( + <> + +
+ {custom.map((view) => { + const isActive = activeView?.id === view.id + return ( + + ) + })} +
+ + )} + + {hasOverrides && ( + + (filtered) + + )} + + + + + + Save current view + +
+ ) +} + +// ── ViewHeader ───────────────────────────────────────────── + +function ViewHeader({ + activeView, + hasOverrides, + onEdit, + onDuplicate, + onDelete, +}: { + activeView: DataViewState | undefined + hasOverrides: boolean + onEdit: () => void + onDuplicate: () => void + onDelete: () => void +}) { + if (!activeView) return null + + return ( +
+

+ {activeView.name} +

+ {hasOverrides && ( + (modified) + )} + + + + + + + + + Edit view + + + + Duplicate + + + + + Delete view + + + + + {activeView.meta?.description && !hasOverrides && ( + <> + + + {activeView.meta.description} + + + )} +
+ ) +} + +// ── Pagination ───────────────────────────────────────────── + +function Pagination({ + page, + pageSize, + totalCount, + onPageChange, + isLoading, +}: { + page: number + pageSize: number + totalCount: number + onPageChange: (page: number) => void + isLoading: boolean +}) { + const totalPages = Math.ceil(totalCount / pageSize) + + return ( +
+ + {totalCount > 0 ? ( + <> + Showing {(page - 1) * pageSize + 1}- + {Math.min(page * pageSize, totalCount)} of {totalCount} issues + + ) : ( + 'No issues found' + )} + +
+ + + Page {page} of {totalPages || 1} + + +
+
+ ) +} + +// ── SortableHeader ───────────────────────────────────────── + +function SortableHeader({ column }: { column: Column }) { + const sortDir = column.getIsSorted() + + if (!column.sortable) { + return {column.displayName} + } + + return ( + + ) +} + +// ── Table ────────────────────────────────────────────────── + +function IssuesTableBody({ + data, + columns, + isLoading, +}: { + data: Issue[] + columns: Column[] + isLoading: boolean +}) { + return ( +
+ + + + ID + + c.id === 'title')!} /> + + + c.id === 'status')!} + /> + + Assignee + Labels + + c.id === 'estimatedHours')!} + /> + + + c.id === 'startDate')!} + /> + + Urgent + + + + {isLoading ? ( + + +
+ + Loading issues... +
+
+
+ ) : data.length === 0 ? ( + + +
+ + No issues match your filters. +
+
+
+ ) : ( + data.map((issue) => { + const StatusIcon = STATUS_ICON_MAP[issue.status.id] + return ( + + + {issue.id.slice(0, 8)} + + {issue.title} + +
+ {StatusIcon && ( + + )} + {issue.status.name} +
+
+ + {issue.assignee ? ( + {issue.assignee.name} + ) : ( + -- + )} + + +
+ {issue.labels.length > 0 ? ( + issue.labels.map((label) => ( + + {label.name} + + )) + ) : ( + + -- + + )} +
+
+ + {issue.estimatedHours}h + + + {issue.startDate ? ( + format(new Date(issue.startDate), 'MMM d, yyyy') + ) : ( + -- + )} + + + {issue.isUrgent && ( + + )} + +
+ ) + }) + )} +
+
+
+ ) +} + +// ── Main Component ───────────────────────────────────────── + +export function IssuesTable() { + const [views, setViews] = useState(PRESET_VIEWS) + const [_, setEditingViewId] = useState(null) + const [page, setPage] = useState(1) + + // ── Fetch options for option-based columns via React Query ── + const { data: statuses } = useQuery({ + queryKey: ['data-view-options', 'statuses'], + queryFn: () => fetchStatuses(), + staleTime: 10 * 60 * 1000, // 10 min — statuses rarely change + }) + + const { data: users } = useQuery({ + queryKey: ['data-view-options', 'users'], + queryFn: () => fetchUsers(), + staleTime: 5 * 60 * 1000, // 5 min + }) + + const { data: labels } = useQuery({ + queryKey: ['data-view-options', 'labels'], + queryFn: () => fetchLabels(), + staleTime: 2 * 60 * 1000, // 2 min + }) + + const statusOptions = useMemo( + () => statuses?.map((s) => ({ label: s.name, value: s.id })), + [statuses], + ) + + const userOptions = useMemo( + () => users?.map((u) => ({ label: u.name, value: u.id })), + [users], + ) + + const labelOptions = useMemo( + () => labels?.map((l) => ({ label: l.name, value: l.id })), + [labels], + ) + + const dataView = useDataView({ + strategy: 'server', + data: [] as Issue[], + columnsConfig, + defaultBaseView: DEFAULT_VIEW, + entityName: 'issues', + options: { + status: statusOptions, + assignee: userOptions, + labels: labelOptions, + }, + }) + + const { columns, baseView, overrides, sort, view, snapshot } = dataView + const hasOverrides = overrides.filters.length > 0 || overrides.sort.length > 0 + + const activeView = useMemo( + () => findActiveView(views, baseView), + [views, baseView], + ) + + // Reset page when filters/sort change + const viewKey = JSON.stringify({ filters: view.filters, sort: view.sort }) + useEffect(() => { + setPage(1) + }, [viewKey]) + + // ── Server data fetching via React Query ── + const { data: queryResult, isLoading } = useQuery({ + queryKey: ['issues', view.filters, view.sort, page, PAGE_SIZE], + queryFn: () => + fetchIssues({ + view: { filters: view.filters, sort: view.sort }, + page, + pageSize: PAGE_SIZE, + }), + placeholderData: (prev) => prev, // keep previous data while loading + }) + + const issues = queryResult?.data ?? [] + const totalCount = queryResult?.totalCount ?? 0 + + // ── View CRUD ── + + const handleLoadView = useCallback( + (v: DataViewState) => { + setEditingViewId(null) + baseView.load(v) + }, + [baseView], + ) + + const handleSaveNewView = useCallback(() => { + const id = `custom-${Date.now()}` + const snap = snapshot({ + id, + name: `Saved View ${views.filter((v) => !v.meta?.isPreset).length + 1}`, + }) + const newView: DataViewState = { + id, + name: snap.name ?? 'Untitled View', + filters: snap.filters, + sort: snap.sort, + meta: { description: '', isPreset: false }, + } + setViews((prev) => [...prev, newView]) + baseView.load(newView) + setEditingViewId(id) + }, [views, snapshot, baseView]) + + const handleEditView = useCallback(() => { + if (activeView && !activeView.meta?.isPreset) { + setEditingViewId(activeView.id ?? null) + } + }, [activeView]) + + const handleDuplicateView = useCallback(() => { + if (!activeView) return + const id = `custom-${Date.now()}` + const duplicate: DataViewState = { + id, + name: `Copy of ${activeView.name}`, + filters: [...activeView.filters], + sort: [...activeView.sort], + meta: { description: activeView.meta?.description, isPreset: false }, + } + setViews((prev) => [...prev, duplicate]) + baseView.load(duplicate) + setEditingViewId(id) + }, [activeView, baseView]) + + const handleDeleteView = useCallback(() => { + if (!activeView || activeView.meta?.isPreset) return + setViews((prev) => prev.filter((v) => v.id !== activeView.id)) + setEditingViewId(null) + baseView.load(PRESET_VIEWS[0]!) + }, [activeView, baseView]) + + // ── Render ── + + return ( + + +
+ {/* View Switcher */} + + + {/* View Header */} + + + {/* Toolbar */} +
+
+ + + {({ filter, column }) => ( + + + + + + + )} + + +
+
+ {isLoading && ( + + )} + + + Server-side + + {sort.length > 0 && ( + + )} +
+
+ + {/* Table */} + + + {/* Pagination */} + + + {/* Debug info */} +
+ + View state (debug) + +
+              {JSON.stringify(
+                {
+                  strategy: 'server',
+                  page,
+                  pageSize: PAGE_SIZE,
+                  totalCount,
+                  resultCount: issues.length,
+                  activeView: activeView
+                    ? {
+                        id: activeView.id,
+                        name: activeView.name,
+                        description: activeView.meta?.description,
+                      }
+                    : null,
+                  view: { filters: view.filters, sort: view.sort },
+                },
+                null,
+                2,
+              )}
+            
+
+
+
+
+ ) +} diff --git a/apps/web/app/examples/data-views/server/_/types.ts b/apps/web/app/examples/data-views/server/_/types.ts new file mode 100644 index 00000000..2cf42137 --- /dev/null +++ b/apps/web/app/examples/data-views/server/_/types.ts @@ -0,0 +1,45 @@ +import type { LucideIcon } from 'lucide-react' + +declare module '@bazza-ui/data-view' { + interface DataViewStateMeta { + description?: string + isPreset?: boolean + } +} + +/** Hydrated issue type — matches the shape returned by the server action. */ +export type Issue = { + id: string + title: string + description: string | null + status: IssueStatus + assignee: User | null + labels: IssueLabel[] + priority: number + estimatedHours: number + startDate: string | null + isUrgent: boolean + createdAt: string +} + +export type User = { + id: string + name: string + email: string + picture: string | null +} + +export type IssueLabel = { + id: string + name: string + color: string +} + +export type IssueStatus = { + id: string + name: string + order: number +} + +/** The icon mapping is kept client-side only (icons aren't serializable). */ +export const STATUS_ICONS: Record = {} diff --git a/apps/web/app/examples/data-views/server/_/views.ts b/apps/web/app/examples/data-views/server/_/views.ts new file mode 100644 index 00000000..1cf80fad --- /dev/null +++ b/apps/web/app/examples/data-views/server/_/views.ts @@ -0,0 +1,94 @@ +import type { DataViewState } from '@bazza-ui/data-view/react' +import type {} from './types' // import module augmentation + +export const PRESET_VIEWS: DataViewState[] = [ + { + id: 'all', + name: 'All Issues', + filters: [], + sort: [], + meta: { isPreset: true }, + }, + { + id: 'bugs', + name: 'Bugs', + filters: [ + { + columnId: 'labels', + type: 'multiOption', + operator: 'include', + values: ['l1'], + }, + ], + sort: [ + { + type: 'column', + columnId: 'estimatedHours', + direction: 'desc', + }, + ], + meta: { isPreset: true, description: 'Issues labeled as bugs' }, + }, + { + id: 'in-progress', + name: 'In Progress', + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['in-progress'], + }, + ], + sort: [ + { + type: 'column', + columnId: 'startDate', + direction: 'desc', + }, + ], + meta: { isPreset: true, description: 'Currently being worked on' }, + }, + { + id: 'urgent', + name: 'Urgent', + filters: [ + { + columnId: 'isUrgent', + type: 'boolean', + operator: 'is', + values: [true], + }, + ], + sort: [ + { + type: 'column', + columnId: 'estimatedHours', + direction: 'asc', + }, + ], + meta: { isPreset: true, description: 'High-priority issues' }, + }, + { + id: 'backlog', + name: 'Backlog', + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['backlog'], + }, + ], + sort: [ + { + type: 'column', + columnId: 'title', + direction: 'asc', + }, + ], + meta: { isPreset: true, description: 'Not yet planned' }, + }, +] + +export const DEFAULT_VIEW = PRESET_VIEWS[0]! diff --git a/apps/web/app/examples/data-views/server/page.tsx b/apps/web/app/examples/data-views/server/page.tsx new file mode 100644 index 00000000..a6411200 --- /dev/null +++ b/apps/web/app/examples/data-views/server/page.tsx @@ -0,0 +1,36 @@ +'use client' + +import { NavBar } from '@/components/nav-bar' +import { IssuesTable } from './_/issues-table' + +export default function ServerDataViewsExamplePage() { + return ( +
+
+
+ +
+
+
+
+
+
+

+ Data View{' '} + (Server-Side) +

+

+ Server-side filtering, sorting, and pagination powered by{' '} + + @bazza-ui/data-view/drizzle/pg + {' '} + — real PostgreSQL queries via Drizzle ORM + Neon. +

+
+ +
+
+
+
+ ) +} diff --git a/apps/web/components/component-preview.tsx b/apps/web/components/component-preview.tsx index 639cf7f7..fc586715 100644 --- a/apps/web/components/component-preview.tsx +++ b/apps/web/components/component-preview.tsx @@ -1,9 +1,10 @@ -'use client' - -import { Suspense, useMemo } from 'react' -import { getRegistryComponent } from '@/lib/registry' +import { Suspense } from 'react' import { cn } from '@/lib/utils' +const componentPreviewLoaders = { + 'filter-variants': () => import('@/registry/examples/filter-variants'), +} as const + export interface ComponentPreviewProps { /** * The name of the registry item to preview @@ -33,14 +34,16 @@ export interface ComponentPreviewProps { * ComponentPreview renders a registry component with lazy loading and Suspense. * Used in documentation pages to show live component examples. */ -export function ComponentPreview({ +export async function ComponentPreview({ name, description, className, align = 'center', bordered = true, }: ComponentPreviewProps) { - const Component = useMemo(() => getRegistryComponent(name), [name]) + const load = + componentPreviewLoaders[name as keyof typeof componentPreviewLoaders] + const Component = load ? (await load()).default : null if (!Component) { return ( diff --git a/apps/web/components/example-client.tsx b/apps/web/components/example-client.tsx index 36bf504e..ef3f7e40 100644 --- a/apps/web/components/example-client.tsx +++ b/apps/web/components/example-client.tsx @@ -12,7 +12,6 @@ import { useRef, useState, } from 'react' -import { getRegistryComponent } from '@/lib/registry' import { cn } from '@/lib/utils' import { Collapsible, @@ -52,6 +51,10 @@ export interface ExampleClientProps { * These are parsed to extract previewCode and previewComponent */ compoundChildren?: ReactNode + /** + * The live preview element for this example + */ + preview: ReactNode } interface ExampleCodeContentProps { @@ -121,6 +124,7 @@ export const ExamplePreviewComponentContent = export function ExampleClient({ name, + preview, className, align = 'center', fileNames, @@ -131,8 +135,6 @@ export function ExampleClient({ const [isOpen, setIsOpen] = useState(false) const [isStuck, setIsStuck] = useState(false) const sentinelRef = useRef(null) - const Component = useMemo(() => getRegistryComponent(name), [name]) - // Parse compound children to extract previewCode and previewComponent const { previewCode, previewComponent } = useMemo(() => { let previewCode: ReactNode = null @@ -178,7 +180,7 @@ export function ExampleClient({ const codeBlocks = Children.toArray(children) const hasPreviewCode = !!previewCode - if (!Component) { + if (!preview) { return (
{/* Preview area */} - }> - - + }>{preview} {/* Preview code section - shown when collapsed and previewCode is provided */} diff --git a/apps/web/components/example.tsx b/apps/web/components/example.tsx index ebc3b871..3beffe9a 100644 --- a/apps/web/components/example.tsx +++ b/apps/web/components/example.tsx @@ -1,6 +1,8 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import type { ReactNode } from 'react' import { transformRegistryPaths } from '@/lib/registry' -import { getRegistryEntrySources } from '@/lib/registry.server' +import { getRegistryPreviewEntry } from '@/lib/registry-preview.server' import { ExampleClient, ExampleCodeContent, @@ -52,8 +54,18 @@ async function ExampleRoot({ transformPaths = true, children, }: ExampleProps) { - // Fetch and highlight all source files - const sources = await getRegistryEntrySources(name) + const entry = getRegistryPreviewEntry(name) + const PreviewComponent = entry ? (await entry.load()).default : null + + const sources = (entry?.files ?? []).flatMap((filePath) => { + try { + const fullPath = join(process.cwd(), filePath) + const content = readFileSync(fullPath, 'utf-8') + return [{ path: filePath, content }] + } catch { + return [] + } + }) const processedSources = sources.map((source) => { const content = transformPaths @@ -78,6 +90,7 @@ async function ExampleRoot({ return ( : null} className={className} align={align} fileNames={fileNames} diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts new file mode 100644 index 00000000..4df0fee9 --- /dev/null +++ b/apps/web/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './lib/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}) diff --git a/apps/web/lib/db/index.ts b/apps/web/lib/db/index.ts new file mode 100644 index 00000000..0effb265 --- /dev/null +++ b/apps/web/lib/db/index.ts @@ -0,0 +1,12 @@ +import { neon } from '@neondatabase/serverless' +import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http' +import * as schema from './schema' + +const sql = neon(process.env.DATABASE_URL!) + +export const db: NeonHttpDatabase = drizzle({ + client: sql, + schema, +}) + +export { schema } diff --git a/apps/web/lib/db/schema.ts b/apps/web/lib/db/schema.ts new file mode 100644 index 00000000..057b668f --- /dev/null +++ b/apps/web/lib/db/schema.ts @@ -0,0 +1,57 @@ +import { + boolean, + integer, + pgTable, + serial, + text, + timestamp, +} from 'drizzle-orm/pg-core' + +// ── Reference Tables ──────────────────────────────────────── + +export const statuses = pgTable('statuses', { + id: text('id').primaryKey(), // e.g. 'backlog', 'todo', 'in-progress', 'done' + name: text('name').notNull(), + order: integer('order').notNull().default(0), +}) + +export const users = pgTable('users', { + id: text('id').primaryKey(), // e.g. 'u1', 'u2', ... + name: text('name').notNull(), + email: text('email').notNull(), + picture: text('picture'), +}) + +export const labels = pgTable('labels', { + id: text('id').primaryKey(), // e.g. 'l1', 'l2', ... + name: text('name').notNull(), + color: text('color').notNull(), +}) + +// ── Issues Table ──────────────────────────────────────────── + +export const issues = pgTable('issues', { + id: text('id').primaryKey(), + title: text('title').notNull(), + description: text('description'), + statusId: text('status_id') + .notNull() + .references(() => statuses.id), + assigneeId: text('assignee_id').references(() => users.id), + priority: integer('priority').notNull().default(0), + estimatedHours: integer('estimated_hours').notNull().default(0), + startDate: timestamp('start_date', { mode: 'string' }), + isUrgent: boolean('is_urgent').notNull().default(false), + createdAt: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(), +}) + +// ── Pivot Table (Issues ↔ Labels, many-to-many) ───────────── + +export const issueLabels = pgTable('issue_labels', { + issueId: text('issue_id') + .notNull() + .references(() => issues.id, { onDelete: 'cascade' }), + labelId: text('label_id') + .notNull() + .references(() => labels.id, { onDelete: 'cascade' }), +}) diff --git a/apps/web/lib/registry-preview.server.ts b/apps/web/lib/registry-preview.server.ts new file mode 100644 index 00000000..0e212618 --- /dev/null +++ b/apps/web/lib/registry-preview.server.ts @@ -0,0 +1,143 @@ +import 'server-only' + +import type { ComponentType } from 'react' + +type RegistryPreviewModule = { + default: ComponentType +} + +export interface RegistryPreviewEntry { + files: string[] + load: () => Promise +} + +const registryPreviewEntries: Record = { + 'dropdown-menu-basic': { + files: ['registry/examples/dropdown-menu/basic/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/basic'), + }, + 'dropdown-menu-close-on-click': { + files: ['registry/examples/dropdown-menu/close-on-click/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/close-on-click'), + }, + 'dropdown-menu-hidden-input': { + files: ['registry/examples/dropdown-menu/hidden-input/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/hidden-input'), + }, + 'dropdown-menu-checkbox': { + files: ['registry/examples/dropdown-menu/checkbox/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/checkbox'), + }, + 'dropdown-menu-radio': { + files: ['registry/examples/dropdown-menu/radio/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/radio'), + }, + 'dropdown-menu-submenu': { + files: ['registry/examples/dropdown-menu/submenu/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/submenu'), + }, + 'dropdown-menu-search': { + files: [ + 'registry/examples/dropdown-menu/search/index.tsx', + 'registry/examples/dropdown-menu/search/data.ts', + ], + load: () => import('@/registry/examples/dropdown-menu/search'), + }, + 'dropdown-menu-deep-search': { + files: ['registry/examples/dropdown-menu/deep-search/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/deep-search'), + }, + 'dropdown-menu-deep-search-linear': { + files: [ + 'registry/examples/dropdown-menu/deep-search-linear/index.tsx', + 'registry/examples/dropdown-menu/deep-search-linear/components.tsx', + 'registry/examples/dropdown-menu/deep-search-linear/icons.tsx', + ], + load: () => import('@/registry/examples/dropdown-menu/deep-search-linear'), + }, + 'dropdown-menu-deep-search-linear-async': { + files: [ + 'registry/examples/dropdown-menu/deep-search-linear-async/index.tsx', + 'registry/examples/dropdown-menu/deep-search-linear-async/components.tsx', + 'registry/examples/dropdown-menu/deep-search-linear-async/icons.tsx', + ], + load: () => + import('@/registry/examples/dropdown-menu/deep-search-linear-async'), + }, + 'dropdown-menu-async-deep-search': { + files: ['registry/examples/dropdown-menu/async-deep-search/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/async-deep-search'), + }, + 'dropdown-menu-virtualized': { + files: ['registry/examples/dropdown-menu/virtualized/index.tsx'], + load: () => import('@/registry/examples/dropdown-menu/virtualized'), + }, + 'dropdown-menu-virtualized-advanced': { + files: ['registry/examples/dropdown-menu/virtualized-advanced/index.tsx'], + load: () => + import('@/registry/examples/dropdown-menu/virtualized-advanced'), + }, + 'guides/dropdown-menu/your-first-menu/surface-hidden-input': { + files: [ + 'registry/examples/dropdown-menu/guides/your-first-menu/surface-hidden-input.tsx', + ], + load: () => + import( + '@/registry/examples/dropdown-menu/guides/your-first-menu/surface-hidden-input' + ), + }, + 'guides/dropdown-menu/your-first-menu/01-initial': { + files: [ + 'registry/examples/dropdown-menu/guides/your-first-menu/items-01.tsx', + ], + load: () => + import( + '@/registry/examples/dropdown-menu/guides/your-first-menu/items-01' + ), + }, + 'guides/dropdown-menu/your-first-menu/items-02': { + files: [ + 'registry/examples/dropdown-menu/guides/your-first-menu/items-02.tsx', + ], + load: () => + import( + '@/registry/examples/dropdown-menu/guides/your-first-menu/items-02' + ), + }, + 'select-basic': { + files: ['registry/examples/select/basic/index.tsx'], + load: () => import('@/registry/examples/select/basic'), + }, + 'select-groups': { + files: ['registry/examples/select/groups/index.tsx'], + load: () => import('@/registry/examples/select/groups'), + }, + 'select-search': { + files: ['registry/examples/select/search/index.tsx'], + load: () => import('@/registry/examples/select/search'), + }, + 'select-form': { + files: ['registry/examples/select/form/index.tsx'], + load: () => import('@/registry/examples/select/form'), + }, + 'select-object-values': { + files: ['registry/examples/select/object-values/index.tsx'], + load: () => import('@/registry/examples/select/object-values'), + }, +} + +export function getRegistryPreviewEntry( + name: string, +): RegistryPreviewEntry | null { + return registryPreviewEntries[name] ?? null +} + +export async function loadRegistryPreviewComponent(name: string) { + const entry = getRegistryPreviewEntry(name) + if (!entry) { + return null + } + + const mod = await entry.load() + return mod.default +} diff --git a/apps/web/mdx-components.tsx b/apps/web/mdx-components.tsx index 576f291d..a49406f9 100644 --- a/apps/web/mdx-components.tsx +++ b/apps/web/mdx-components.tsx @@ -1,7 +1,6 @@ import { ConstructionIcon, LinkIcon } from 'lucide-react' import type { MDXComponents } from 'mdx/types' import Image from 'next/image' -import { IssuesTableWrapper } from '@/app/demos/client/tst-static/_/issues-table-wrapper' import { BaseUIReference } from '@/components/base-ui-reference' import { CssSelector } from '@/components/css-selector' import { CssVarsTable } from '@/components/css-vars-table' @@ -25,7 +24,7 @@ import { import CollapsibleCodeBlock from './components/collapsible-code-block' import ComponentCode from './components/component-code' import { ComponentFrame } from './components/component-frame' -import { ComponentPreview } from './components/component-preview' +import type { ComponentPreviewProps } from './components/component-preview' import { ComponentsList } from './components/components-list' import { Example } from './components/example' import { Examples } from './components/examples' @@ -54,6 +53,23 @@ const HeadingAnchor = ({ ) +const IssuesTableWrapper = async () => { + const { IssuesTableWrapper } = await import( + '@/app/demos/client/tst-static/_/issues-table-wrapper' + ) + + return +} + +const ComponentPreview = async ({ + className, + ...props +}: ComponentPreviewProps) => { + const { ComponentPreview } = await import('./components/component-preview') + + return +} + const components = { h1: (props) => (

@@ -281,13 +297,9 @@ const components = { DataAttrsTable, CssVarsTable, CssSelector, - IssuesTableWrapper, // @ts-expect-error Examples, Example, - ComponentPreview: ({ className, ...props }) => ( - - ), ComponentCode: ComponentCode, ComponentFrame, CodeInline, @@ -326,6 +338,23 @@ const components = { CodeBlockTab, } satisfies MDXComponents -export function useMDXComponents(): MDXComponents { - return components as any +interface UseMDXComponentsOptions { + slug?: string[] +} + +export function useMDXComponents({ + slug, +}: UseMDXComponentsOptions = {}): MDXComponents { + const includeFilterDemos = + slug?.includes('filters') || slug?.includes('data-table-filter') + + if (!includeFilterDemos) { + return components as any + } + + return { + ...components, + IssuesTableWrapper, + ComponentPreview, + } as any } diff --git a/apps/web/package.json b/apps/web/package.json index 2f915302..6e53fec1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "scripts": { "generate-fonts": "tsx scripts/generate-fonts.ts", "predev": "bun run generate-fonts", - "dev": "next dev --turbopack", + "dev": "next dev --turbopack --port 3010", "prebuild": "bun run generate-fonts", "gen-types": "tsx scripts/build-types-meta.ts --out .types/types-meta.json --tsconfig tsconfig.json --pkg @bazza-ui/filters=../../packages/filters/src/index.ts --pkg @bazza-ui/react=../../packages/react/src/index.ts --pkg @registry/filter=registry/ui/filter/index.ts --pkg @html-element-props=types/html-element-props.ts", "build": "next build --turbopack", @@ -24,12 +24,14 @@ }, "dependencies": { "@base-ui/react": "^1.0.0", + "@bazza-ui/data-view": "workspace:*", "@bazza-ui/filters": "workspace:*", "@bazza-ui/react": "workspace:*", "@hookform/resolvers": "^5.2.2", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@ndaidong/txtgen": "^4.0.1", + "@neondatabase/serverless": "^1.0.2", "@next/mdx": "16.1.1", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.3.1", @@ -57,6 +59,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.1", "fumadocs-core": "^16.1.0", "fumadocs-mdx": "^14.0.3", "fumadocs-ui": "^16.1.0", @@ -65,7 +68,7 @@ "lucide-react": "^0.555.0", "motion": "^12.23.24", "nanoid": "^5.1.5", - "next": "16.1.1", + "next": "16.1.6", "next-themes": "^0.4.6", "nuqs": "^2.4.1", "react": "19.2.3", @@ -102,6 +105,7 @@ "@types/react-virtualized": "^9.22.3", "@types/unist": "^3.0.3", "chokidar": "^4.0.3", + "drizzle-kit": "^0.31.8", "husky": "^9.1.7", "jsdom": "^26.0.0", "mdast-util-mdx-jsx": "^3.2.0", diff --git a/apps/web/registry/ui/data-view/components/actions/filter-actions.tsx b/apps/web/registry/ui/data-view/components/actions/filter-actions.tsx new file mode 100644 index 00000000..9c66acb6 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/actions/filter-actions.tsx @@ -0,0 +1,89 @@ +'use client' + +import { type Locale, t } from '@bazza-ui/data-view' +import type { ViewLayer } from '@bazza-ui/data-view/react' + +import { type ComponentPropsWithoutRef, forwardRef } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useDataViewContext } from '../root/data-view-context' + +export interface FilterActionsProps + extends Omit, 'onClick' | 'variant'> { + hasFilters?: boolean + layer?: ViewLayer + locale?: Locale + variant?: ComponentPropsWithoutRef['variant'] +} + +/** + * Button to clear all filters. + * Renders a ` + ) + }, +) + +FilterActions.displayName = 'FilterActions' + +export const ListFilterMinusIcon = (props: React.SVGProps) => ( + + + + + + + +) + +export namespace FilterActions { + export type Props = FilterActionsProps +} diff --git a/apps/web/registry/ui/data-view/components/item/filter-item.tsx b/apps/web/registry/ui/data-view/components/item/filter-item.tsx new file mode 100644 index 00000000..ed659bd4 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/item/filter-item.tsx @@ -0,0 +1,132 @@ +'use client' + +import type { + Column, + ColumnDataType, + FilterModel, + FilterStrategy, + Locale, + ViewLayer, +} from '@bazza-ui/data-view/react' +import { cva, type VariantProps } from 'class-variance-authority' +import { + type ComponentPropsWithoutRef, + createContext, + forwardRef, + useContext, +} from 'react' +import { cn } from '@/lib/utils' +import { + useDataViewContext, + useDataViewVariant, +} from '../root/data-view-context' + +const filterItemVariants = cva('flex items-center text-xs font-medium', { + variants: { + variant: { + default: + 'h-7 rounded-md border border-border bg-background shadow-xs divide-x', + clean: 'h-7.5 rounded-md bg-accent border-none shadow-none gap-x-1 px-1', + }, + }, + defaultVariants: { + variant: 'default', + }, +}) + +export interface FilterItemContextValue< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, +> { + filter: FilterModel + column: Column + layer: ViewLayer + strategy: FilterStrategy + locale: Locale + entityName?: string +} + +export const FilterItemContext = createContext( + null, +) + +/** + * Returns the FilterItemContext value, or null if not within a FilterItem. + * This allows child components to be used both inside FilterItem (consuming context) + * or standalone (with explicit props). + */ +export function useFilterItemContext< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, +>(): FilterItemContextValue | null { + return useContext(FilterItemContext) as FilterItemContextValue< + TData, + TType + > | null +} + +export interface FilterItemProps< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, +> extends Omit, 'children'>, + VariantProps { + filter: FilterModel + column: Column + children?: React.ReactNode +} + +/** + * Container for a single filter's controls (subject, operator, value, remove). + * Renders a `
` element. + * + * Documentation: [Bazza UI DataView](https://bazza-ui.com/docs/components/data-view) + */ +const FilterItem = forwardRef( + ( + { filter, column, children, className, variant: variantProp, ...props }, + ref, + ) => { + const dataViewContext = useDataViewContext() + const contextVariant = useDataViewVariant() + const variant = variantProp ?? contextVariant ?? 'default' + + const itemContextValue: FilterItemContextValue = { + filter, + column, + layer: dataViewContext.layer, + strategy: dataViewContext.strategy, + locale: dataViewContext.locale, + entityName: dataViewContext.entityName, + } + + return ( + +
+ {children} +
+
+ ) + }, +) + +FilterItem.displayName = 'FilterItem' + +export { FilterItem, filterItemVariants } + +export namespace FilterItem { + export type Props< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, + > = FilterItemProps + export type ContextValue< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, + > = FilterItemContextValue +} diff --git a/apps/web/registry/ui/data-view/components/item/filter-operator.tsx b/apps/web/registry/ui/data-view/components/item/filter-operator.tsx new file mode 100644 index 00000000..9fa7352d --- /dev/null +++ b/apps/web/registry/ui/data-view/components/item/filter-operator.tsx @@ -0,0 +1,230 @@ +'use client' + +import { getOperatorSet } from '@bazza-ui/data-view' +import type { + Column, + ColumnDataType, + FilterModel, + Locale, + ViewLayer, +} from '@bazza-ui/data-view/react' +import type { + NodeDef, + RadioGroupDef, + RadioGroupRenderParams, + RadioItemDef, + RadioItemRenderParams, +} from '@bazza-ui/react' +import { cva, type VariantProps } from 'class-variance-authority' +import { type ComponentPropsWithoutRef, forwardRef, useMemo } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { DropdownMenu, LabelWithBreadcrumbs } from '@/registry/ui/dropdown-menu' +import { + useDataViewLayer, + useDataViewLocale, + useDataViewVariant, +} from '../root/data-view-context' +import { useFilterItemContext } from './filter-item' + +const filterOperatorVariants = cva( + 'm-0 w-fit whitespace-nowrap p-0 px-2 text-xs text-muted-foreground', + { + variants: { + variant: { + default: 'h-full rounded-none', + clean: + 'border-none h-6 rounded-md shadow-xs bg-background hover:bg-background aria-expanded:bg-background aria-expanded:text-primary', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export interface FilterOperatorProps< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, +> extends Omit, 'onClick' | 'variant'>, + VariantProps { + /** The column configuration. If omitted, will be read from FilterItem context. */ + column?: Column + /** The current filter state. If omitted, will be read from FilterItem context. */ + filter?: FilterModel + /** View layer. If omitted, will be read from FilterItem or DataView context. */ + layer?: ViewLayer + locale?: Locale +} + +interface CreateOperatorNodesParams { + filter: FilterModel + column: Column + layer: ViewLayer +} + +function createOperatorNodes({ + filter, + column, + layer, +}: CreateOperatorNodesParams): NodeDef[] { + const operatorSet = getOperatorSet(column) + const currentOp = operatorSet.has(filter.operator) + ? operatorSet.get(filter.operator) + : undefined + + // Get related operators (same target as current) + const relatedOps = currentOp + ? operatorSet.all().filter((op) => op.target === currentOp.target) + : operatorSet.all() + + const radioGroup: RadioGroupDef = { + kind: 'radio-group', + id: 'operators', + value: filter.operator, + onValueChange: (value) => { + layer.setFilterOperator(column.id, value) + }, + render: ({ props, children }: RadioGroupRenderParams) => ( + + {children} + + ), + nodes: relatedOps.map((op, index): RadioItemDef => { + const operatorLabel = op.label + const shortcut = index < 9 ? String(index + 1) : undefined + return { + kind: 'radio-item', + value: op.id, + keywords: [operatorLabel], + shortcut, + closeOnClick: true, + render: ({ props }: RadioItemRenderParams) => { + return ( + + +
+ + +
+
+ ) + }, + } + }), + } + + return [radioGroup] +} + +/** + * Displays and allows changing the filter operator (e.g., "is", "contains"). + * Renders a ` + ) + }, +) + +FilterRemove.displayName = 'FilterRemove' + +export { FilterRemove, filterRemoveVariants } + +export namespace FilterRemove { + export type Props = FilterRemoveProps +} diff --git a/apps/web/registry/ui/data-view/components/item/filter-subject.tsx b/apps/web/registry/ui/data-view/components/item/filter-subject.tsx new file mode 100644 index 00000000..ad00669d --- /dev/null +++ b/apps/web/registry/ui/data-view/components/item/filter-subject.tsx @@ -0,0 +1,113 @@ +'use client' + +import type { Column, ColumnDataType } from '@bazza-ui/data-view/react' +import { isBooleanColumn } from '@bazza-ui/data-view/react' +import { cva, type VariantProps } from 'class-variance-authority' +import { + type ComponentPropsWithoutRef, + forwardRef, + isValidElement, +} from 'react' +import { cn } from '@/lib/utils' +import { + useDataViewEntityName, + useDataViewVariant, +} from '../root/data-view-context' +import { useFilterItemContext } from './filter-item' + +const filterSubjectVariants = cva( + 'flex select-none items-center gap-1.5 whitespace-nowrap px-2 h-full', + { + variants: { + variant: { + default: 'font-medium', + clean: 'text-primary/75', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export interface FilterSubjectProps< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, +> extends ComponentPropsWithoutRef<'span'>, + VariantProps { + /** The column configuration. If omitted, will be read from FilterItem context. */ + column?: Column + entityName?: string +} + +/** + * Displays the column name/subject for a filter. + * Renders a `` element. + * + * Documentation: [Bazza UI DataView](https://bazza-ui.com/docs/components/data-view) + */ +const FilterSubject = forwardRef( + ( + { + column: columnProp, + entityName: entityNameProp, + className, + variant: variantProp, + ...props + }, + ref, + ) => { + const itemContext = useFilterItemContext() + const dataViewEntityName = useDataViewEntityName() + const contextVariant = useDataViewVariant() + + const column = columnProp ?? itemContext?.column + const entityName = + entityNameProp ?? itemContext?.entityName ?? dataViewEntityName + const variant = variantProp ?? contextVariant ?? 'default' + + if (!column) { + throw new Error( + 'FilterSubject requires a column prop or must be used within FilterItem', + ) + } + + const subject = isBooleanColumn(column) ? entityName : column.displayName + + // column.icon is typed as `unknown` in data-view + const Icon = column.icon as + | React.ComponentType<{ className?: string }> + | undefined + const hasIcon = !!Icon + + return ( + + {hasIcon && + (isValidElement(Icon) ? ( + Icon + ) : ( + + ))} + + {subject} + + ) + }, +) + +FilterSubject.displayName = 'FilterSubject' + +export { FilterSubject, filterSubjectVariants } + +export namespace FilterSubject { + export type Props< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, + > = FilterSubjectProps +} diff --git a/apps/web/registry/ui/data-view/components/item/filter-value.tsx b/apps/web/registry/ui/data-view/components/item/filter-value.tsx new file mode 100644 index 00000000..9786190c --- /dev/null +++ b/apps/web/registry/ui/data-view/components/item/filter-value.tsx @@ -0,0 +1,486 @@ +'use client' + +import type { + Column, + ColumnDataType, + FilterModel, + FilterStrategy, + Locale, + ViewLayer, +} from '@bazza-ui/data-view/react' +import { + isBooleanColumn, + isBooleanFilter, + isDateColumn, + isDateFilter, + isMultiOptionColumn, + isMultiOptionFilter, + isNumberColumn, + isNumberFilter, + isOptionColumn, + isOptionFilter, + isTextColumn, + isTextFilter, + take, +} from '@bazza-ui/data-view/react' + +import { cva } from 'class-variance-authority' +import { format } from 'date-fns' +import { Ellipsis } from 'lucide-react' +import * as React from 'react' +import { + cloneElement, + forwardRef, + isValidElement, + useMemo, + useRef, + useState, +} from 'react' +import { Button, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { DropdownMenu } from '@/registry/ui/dropdown-menu' +import { + type DataViewVariant, + useDataViewEntityName, + useDataViewLayer, + useDataViewLocale, + useDataViewStrategy, + useDataViewVariant, +} from '../root/data-view-context' +import { + FilterValueDateController, + FilterValueNumberController, + OptionEditorContent, + TextEditorContent, +} from '../value' +import { useFilterItemContext } from './filter-item' + +const filterValueVariants = cva( + 'm-0 w-fit whitespace-nowrap p-0 px-2 text-xs', + { + variants: { + variant: { + default: 'h-full rounded-none', + clean: + 'h-6 rounded-md text-primary/75 hover:text-primary hover:bg-background hover:shadow-xs aria-expanded:bg-background aria-expanded:text-primary', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export interface FilterValueProps< + TData = unknown, + TType extends ColumnDataType = ColumnDataType, +> { + /** The current filter state. If omitted, will be read from FilterItem context. */ + filter?: FilterModel + /** The column configuration. If omitted, will be read from FilterItem context. */ + column?: Column + /** View layer. If omitted, will be read from FilterItem or DataView context. */ + layer?: ViewLayer + /** Filter strategy. If omitted, will be read from FilterItem or DataView context. */ + strategy?: FilterStrategy + locale?: Locale + entityName?: string + className?: string + variant?: DataViewVariant +} + +/** + * Displays and allows editing the filter value. + * Renders a ` +
+ ) + }, +) + +SortItem.displayName = 'SortItem' + +export { SortItem, sortItemVariants } + +export namespace SortItem { + export type Props = SortItemProps +} diff --git a/apps/web/registry/ui/data-view/components/sort/sort-menu.tsx b/apps/web/registry/ui/data-view/components/sort/sort-menu.tsx new file mode 100644 index 00000000..8691059d --- /dev/null +++ b/apps/web/registry/ui/data-view/components/sort/sort-menu.tsx @@ -0,0 +1,156 @@ +'use client' + +import type { Column, SortRule, ViewLayer } from '@bazza-ui/data-view/react' +import type { ItemDef, ItemRenderParams, NodeDef } from '@bazza-ui/react' +import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from 'lucide-react' +import * as React from 'react' +import { isValidElement, memo, useMemo } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { DropdownMenu, LabelWithBreadcrumbs } from '@/registry/ui/dropdown-menu' +import { + type DataViewVariant, + useDataViewContext, +} from '../root/data-view-context' + +export interface SortMenuProps { + columns?: Column[] + sort?: SortRule[] + layer?: ViewLayer + children?: React.ReactElement + rootProps?: Partial< + Omit, 'children'> + > + variant?: DataViewVariant +} + +function renderColumnIcon(icon: unknown): React.ReactNode { + if (!icon) return null + + return ( +
+ {isValidElement(icon) ? ( + icon + ) : ( + + {React.createElement( + icon as React.ComponentType<{ className?: string }>, + { + className: + 'size-4 shrink-0 text-muted-foreground group-data-[highlighted]/row:text-primary', + }, + )} + + )} +
+ ) +} + +function __SortMenu({ + columns: columnsProp, + sort: sortProp, + layer: layerProp, + children, + rootProps, +}: SortMenuProps) { + const context = useDataViewContext() + + const columns = columnsProp ?? context.columns + const sort = sortProp ?? context.sort + const layer = layerProp ?? context.layer + + const sortableColumns = useMemo( + () => columns.filter((c) => c.sortable && !c.hidden), + [columns], + ) + + const nodes: NodeDef[] = useMemo( + () => + sortableColumns.map((column): ItemDef => { + const currentSort = sort.find( + (s): s is SortRule & { type: 'column' } => + s.type === 'column' && s.columnId === column.id, + ) + const sortDir = currentSort?.direction + + return { + kind: 'item', + id: `sort-${column.id}`, + value: column.displayName, + keywords: [column.displayName], + onSelect: () => { + layer.toggleColumnSort(column.id) + }, + render: ({ context: itemContext }: ItemRenderParams) => ( + +
+ {renderColumnIcon(column.icon)} + +
+
+ {sortDir === 'asc' ? ( + + ) : sortDir === 'desc' ? ( + + ) : ( + + )} +
+
+ ), + } + }), + [sortableColumns, sort, layer], + ) + + const hasSorts = sort.length > 0 + + const triggerElement = children ?? ( + + ) + + return ( + + + + + + + + + {({ nodes: displayNodes, renderNode }) => + displayNodes.map(renderNode) + } + + No matching columns. + + + + + + ) +} + +export const SortMenu = memo(__SortMenu) as typeof __SortMenu + +export namespace SortMenu { + export type Props = SortMenuProps +} diff --git a/apps/web/registry/ui/data-view/components/sort/sort-toggle.tsx b/apps/web/registry/ui/data-view/components/sort/sort-toggle.tsx new file mode 100644 index 00000000..f81b40bf --- /dev/null +++ b/apps/web/registry/ui/data-view/components/sort/sort-toggle.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { Column } from '@bazza-ui/data-view/react' +import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from 'lucide-react' +import { type ComponentPropsWithoutRef, forwardRef } from 'react' +import { cn } from '@/lib/utils' + +export interface SortToggleProps + extends ComponentPropsWithoutRef<'button'> { + column: Column +} + +/** + * Inline column header sort toggle button. + * Shows the current sort direction and toggles on click. + * Renders a ` + ) + }, +) + +SortToggle.displayName = 'SortToggle' + +export { SortToggle } + +export namespace SortToggle { + export type Props = SortToggleProps +} diff --git a/apps/web/registry/ui/data-view/components/trigger/filter-trigger.tsx b/apps/web/registry/ui/data-view/components/trigger/filter-trigger.tsx new file mode 100644 index 00000000..8842b297 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/trigger/filter-trigger.tsx @@ -0,0 +1,62 @@ +'use client' + +import { type Locale, t } from '@bazza-ui/data-view' +import { ListFilterIcon } from 'lucide-react' +import { type ComponentPropsWithoutRef, forwardRef } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import type { DataViewVariant } from '../root/data-view-context' + +export interface FilterTriggerProps extends ComponentPropsWithoutRef<'button'> { + hasVisibleFilters?: boolean + locale?: Locale + variant?: DataViewVariant +} + +/** + * A button that opens the filter menu. + * Renders a ` + ) + }, +) + +FilterTrigger.displayName = 'FilterTrigger' + +export { FilterTrigger } + +export namespace FilterTrigger { + export type Props = FilterTriggerProps +} diff --git a/apps/web/registry/ui/data-view/components/value/editors/index.ts b/apps/web/registry/ui/data-view/components/value/editors/index.ts new file mode 100644 index 00000000..50032317 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/editors/index.ts @@ -0,0 +1,9 @@ +// Shared editor content components +export { + OptionEditorContent, + type OptionEditorContentProps, +} from './option-editor' +export { + TextEditorContent, + type TextEditorContentProps, +} from './text-editor' diff --git a/apps/web/registry/ui/data-view/components/value/editors/option-editor.tsx b/apps/web/registry/ui/data-view/components/value/editors/option-editor.tsx new file mode 100644 index 00000000..0c60c02f --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/editors/option-editor.tsx @@ -0,0 +1,140 @@ +'use client' + +import type { + Column, + FilterModel, + FilterStrategy, + Locale, + ViewLayer, +} from '@bazza-ui/data-view/react' +import { isMultiOptionColumn, isOptionColumn } from '@bazza-ui/data-view/react' +import type { + CheckboxItemDef, + NodeDef, + SeparatorDef, + SeparatorRenderParams, +} from '@bazza-ui/react' +import { useMemo } from 'react' +import { DropdownMenu } from '@/registry/ui/dropdown-menu' +import { createMultiOptionMenu, createOptionMenu } from '../selectable-menu' + +export interface OptionEditorContentProps { + column: Column | Column + layer: ViewLayer + filter?: FilterModel<'option'> | FilterModel<'multiOption'> + locale: Locale + strategy: FilterStrategy + /** + * Initial values used to determine the separator position. + * Items matching these values appear above the separator. + */ + initialValues?: (string | number | bigint | boolean | Date)[] + /** + * When true, shows a separator between selected and unselected items. + * Used by FilterValue to maintain selection order visibility. + */ + showSeparator?: boolean +} + +/** + * Helper function to partition nodes into selected and unselected. + */ +function partitionNodesBySelection( + nodes: T[], + initialValues: string[], +): { selected: T[]; unselected: T[] } { + const selected = nodes.filter( + (node) => node.id != null && initialValues.includes(node.id), + ) + const unselected = nodes.filter( + (node) => node.id == null || !initialValues.includes(node.id), + ) + return { selected, unselected } +} + +/** + * Helper function to create nodes with a separator between selected and unselected. + */ +function createNodesWithSeparator( + nodes: CheckboxItemDef[], + initialValues: (string | number | bigint | boolean | Date)[], +): NodeDef[] { + const { selected, unselected } = partitionNodesBySelection( + nodes, + initialValues as string[], + ) + + const result: NodeDef[] = [...selected] + + if (selected.length > 0 && unselected.length > 0) { + const separator: SeparatorDef = { + kind: 'separator', + id: 'selected-separator', + render: ({ props }: SeparatorRenderParams) => ( + + ), + } + result.push(separator) + } + + result.push(...unselected) + return result +} + +/** + * Shared option/multiOption editor content for both FilterValue and FilterMenu. + * Provides a searchable list of checkbox items. + */ +export function OptionEditorContent({ + column, + layer, + filter, + locale, + strategy, + initialValues = [], + showSeparator = false, +}: OptionEditorContentProps) { + // Compute static nodes + const nodes = useMemo(() => { + let baseNodes: CheckboxItemDef[] + + if (isOptionColumn(column)) { + const result = createOptionMenu({ + column, + layer, + filter: filter as FilterModel<'option'> | undefined, + locale, + strategy, + }) + baseNodes = result.nodes + } else if (isMultiOptionColumn(column)) { + const result = createMultiOptionMenu({ + column, + layer, + filter: filter as FilterModel<'multiOption'> | undefined, + locale, + strategy, + }) + baseNodes = result.nodes + } else { + baseNodes = [] + } + + if (showSeparator && initialValues.length > 0) { + return createNodesWithSeparator(baseNodes, initialValues) + } + + return baseNodes + }, [column, layer, filter, locale, strategy, initialValues, showSeparator]) + + return ( + + + + No matching options. + + + ) +} diff --git a/apps/web/registry/ui/data-view/components/value/editors/text-editor.tsx b/apps/web/registry/ui/data-view/components/value/editors/text-editor.tsx new file mode 100644 index 00000000..5f2f739f --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/editors/text-editor.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { + Column, + DataViewInstance, + ViewLayer, +} from '@bazza-ui/data-view/react' +import { useMemo, useState } from 'react' +import { cn } from '@/lib/utils' +import { DropdownMenu } from '@/registry/ui/dropdown-menu' +import { createTextFilterItems } from '../text-menu' + +export interface TextEditorContentProps { + column: Column + layer: ViewLayer + instance?: DataViewInstance +} + +/** + * Shared text editor content for both FilterValue and FilterMenu. + * Provides a search input and dynamically generated filter options. + */ +export function TextEditorContent({ + column, + layer, + instance, +}: TextEditorContentProps) { + const [query, setQuery] = useState('') + + const nodes = useMemo( + () => createTextFilterItems({ query, column, layer, instance }), + [query, column, layer, instance], + ) + + return ( + + + + {({ nodes: displayNodes, renderNode }) => + displayNodes.length > 0 ? ( + displayNodes.map(renderNode) + ) : query.trim() ? ( +
+ Press enter to filter by "{query}" +
+ ) : null + } +
+
+ ) +} diff --git a/apps/web/registry/ui/data-view/components/value/filter-value-date-controller.tsx b/apps/web/registry/ui/data-view/components/value/filter-value-date-controller.tsx new file mode 100644 index 00000000..a1cd4485 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/filter-value-date-controller.tsx @@ -0,0 +1,54 @@ +'use client' + +import type { Column, FilterModel, ViewLayer } from '@bazza-ui/data-view/react' +import { isEqual } from 'date-fns' +import { useState } from 'react' +import type { DateRange } from 'react-day-picker' +import { Calendar } from '@/components/ui/calendar' +import { Command, CommandGroup, CommandList } from '@/components/ui/command' +import type { FilterValueControllerProps } from './types' + +export function FilterValueDateController({ + filter, + column, + layer, +}: FilterValueControllerProps) { + const values = filter?.values as Date[] | undefined + const [date, setDate] = useState({ + from: values?.[0] ?? new Date(), + to: values?.[1] ?? undefined, + }) + + function changeDateRange(value: DateRange | undefined) { + const start = value?.from + const end = + start && value && value.to && !isEqual(start, value.to) + ? value.to + : undefined + + setDate({ from: start, to: end }) + + const isRange = start && end + const newValues = isRange ? [start, end] : start ? [start] : [] + + layer.setFilterValue(column, newValues) + } + + return ( + + + +
+ +
+
+
+
+ ) +} diff --git a/apps/web/registry/ui/data-view/components/value/filter-value-number-controller.tsx b/apps/web/registry/ui/data-view/components/value/filter-value-number-controller.tsx new file mode 100644 index 00000000..6749b1e0 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/filter-value-number-controller.tsx @@ -0,0 +1,185 @@ +'use client' + +import { createNumberRange, getOperatorSet, t } from '@bazza-ui/data-view' +import type { Column, Locale, ViewLayer } from '@bazza-ui/data-view/react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Command, CommandGroup, CommandList } from '@/components/ui/command' +import { Slider } from '@/components/ui/slider' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useDebounceCallback } from '../../hooks/use-debounce-callback' +import { DebouncedInput } from '../../ui/debounced-input' +import type { FilterValueControllerProps } from './types' + +export function FilterValueNumberController({ + filter, + column, + layer, + locale = 'en', +}: FilterValueControllerProps) { + type MinMaxReturn = [number, number] | undefined + const minMax = useMemo( + () => column.getFacetedMinMaxValues() as MinMaxReturn, + [column], + ) + const [sliderMin, sliderMax] = [ + minMax ? minMax[0] : 0, + minMax ? minMax[1] : 0, + ] + + // Local state for values + const filterValues = (filter?.values as number[] | undefined) ?? [0, 0] + const [values, setValues] = useState(filterValues) + + // Sync with parent filter changes + useEffect(() => { + if ( + filter?.values && + filter.values.length === values.length && + (filter.values as number[]).every((v, i) => v === values[i]) + ) { + setValues(filter.values as number[]) + } + }, [filter?.values, values]) + + // Check operator target to determine if it's a range + const operatorSet = useMemo(() => getOperatorSet(column), [column]) + const isNumberRange = + filter && + operatorSet.has(filter.operator) && + operatorSet.get(filter.operator).target === 'multiple' + + const setFilterOperatorDebounced = useDebounceCallback( + (columnId: string, operator: string) => + layer.setFilterOperator(columnId, operator), + 500, + ) + + // Create a typed wrapper for setFilterValue to avoid 'as any' casts + const setNumberFilterValue = useCallback( + (col: Column, vals: number[]) => { + layer.setFilterValue(col, vals) + }, + [layer], + ) + const setFilterValueDebounced = useDebounceCallback(setNumberFilterValue, 500) + + const changeNumber = (value: number[]) => { + setValues(value) + setFilterValueDebounced(column, value) + } + + const changeMinNumber = (value: number) => { + const newValues = createNumberRange([value, values[1]!]) + setValues(newValues) + setFilterValueDebounced(column, newValues) + } + + const changeMaxNumber = (value: number) => { + const newValues = createNumberRange([values[0]!, value]) + setValues(newValues) + setFilterValueDebounced(column, newValues) + } + + const changeType = useCallback( + (type: 'single' | 'range') => { + let newValues: number[] = [] + if (type === 'single') + newValues = [values[0]!] // Keep the first value for single mode + else if (!minMax) + newValues = createNumberRange([values[0]!, values[1] ?? 0]) + else { + const value = values[0]! + newValues = + value - minMax[0] < minMax[1] - value + ? createNumberRange([value, minMax[1]]) + : createNumberRange([minMax[0], value]) + } + + const newOperator = type === 'single' ? 'is' : 'is between' + + // Update local state + setValues(newValues) + + // Cancel in-flight debounced calls to prevent flicker/race conditions + setFilterOperatorDebounced.cancel() + setFilterValueDebounced.cancel() + + // Update global filter state atomically + layer.setFilterOperator(column.id, newOperator) + layer.setFilterValue(column, newValues) + }, + [ + values, + column, + layer, + minMax, + setFilterOperatorDebounced, + setFilterValueDebounced, + ], + ) + + return ( +
+ changeType(v as 'single' | 'range')} + > + + {t('single', locale)} + {t('range', locale)} + + + {minMax && ( + changeNumber(value)} + min={sliderMin} + max={sliderMax} + step={1} + aria-orientation="horizontal" + /> + )} +
+ {t('value', locale)} + changeNumber([Number(v)])} + /> +
+
+ + {minMax && ( + + )} +
+
+ {t('min', locale)} + changeMinNumber(Number(v))} + /> +
+
+ {t('max', locale)} + changeMaxNumber(Number(v))} + /> +
+
+
+
+
+ ) +} diff --git a/apps/web/registry/ui/data-view/components/value/index.ts b/apps/web/registry/ui/data-view/components/value/index.ts new file mode 100644 index 00000000..8f5acfc1 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/index.ts @@ -0,0 +1,36 @@ +// Controllers + +// Shared editor content components +export { + OptionEditorContent, + type OptionEditorContentProps, + TextEditorContent, + type TextEditorContentProps, +} from './editors' +export { FilterValueDateController } from './filter-value-date-controller' +export { FilterValueNumberController } from './filter-value-number-controller' +// Option item renderer (for CheckboxItemDef) +export { createOptionItemRenderer, renderOptionItem } from './option-item' +// Menu creators (consolidated) +export { + type CreateMultiOptionMenuProps, + type CreateMultiOptionMenuResult, + type CreateOptionMenuProps, + type CreateOptionMenuResult, + type CreateSelectableMenuResult, + createMultiOptionMenu, + createOptionMenu, + createSelectableMenu, + type SelectableColumnType, +} from './selectable-menu' + +// Text item renderer (for ItemDef) +export { createTextItemRenderer, renderTextItem } from './text-item' +export { createTextFilterItems, type TextFilterItemData } from './text-menu' + +// Types +export type { + FilterValueControllerProps, + FilterValueDisplayProps, + FilterValueProps, +} from './types' diff --git a/apps/web/registry/ui/data-view/components/value/option-item.tsx b/apps/web/registry/ui/data-view/components/value/option-item.tsx new file mode 100644 index 00000000..16b70649 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/option-item.tsx @@ -0,0 +1,97 @@ +'use client' + +import type { ColumnOptionExtended } from '@bazza-ui/data-view' +import type { CheckboxItemRenderParams } from '@bazza-ui/react' +import * as React from 'react' +import { isValidElement } from 'react' +import { Checkbox } from '@/components/ui/checkbox' +import { cn } from '@/lib/utils' +import { DropdownMenu, LabelWithBreadcrumbs } from '@/registry/ui/dropdown-menu' + +/** + * Renders an option/checkbox item for option-based filter values. + * Used as a render function in CheckboxItemDef. + */ +export function renderOptionItem( + option: ColumnOptionExtended, + params: CheckboxItemRenderParams, +): React.ReactNode { + const { props, context } = params + const { id, checked, onCheckedChange, disabled, closeOnClick } = props + + const Icon = option.icon as + | React.ComponentType<{ className?: string }> + | React.ReactElement + | undefined + const hasIcon = !!Icon + + return ( + +
+ ( + { + e.stopPropagation() + state.toggle() + }} + /> + )} + /> + {hasIcon && ( +
+ {isValidElement(Icon) ? ( + Icon + ) : ( + + {React.createElement( + Icon as React.ComponentType<{ className?: string }>, + { + className: + 'size-4 shrink-0 text-muted-foreground group-data-[highlighted]/row:text-primary', + }, + )} + + )} +
+ )} + +
+ {option.count !== undefined && option.count > 0 && ( + + {new Intl.NumberFormat().format(option.count)} + + )} +
+ ) +} + +/** + * Creates a render function for an option item. + * This is used when building CheckboxItemDef nodes. + */ +export function createOptionItemRenderer(option: ColumnOptionExtended) { + return (params: CheckboxItemRenderParams): React.ReactNode => { + return renderOptionItem(option, params) + } +} diff --git a/apps/web/registry/ui/data-view/components/value/selectable-menu.ts b/apps/web/registry/ui/data-view/components/value/selectable-menu.ts new file mode 100644 index 00000000..7f266e9a --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/selectable-menu.ts @@ -0,0 +1,180 @@ +import type { + Column, + ColumnOption, + ColumnOptionExtended, + FilterModel, + FilterStrategy, + Locale, + ViewLayer, +} from '@bazza-ui/data-view/react' +import type { CheckboxItemDef } from '@bazza-ui/react' +import { createOptionItemRenderer } from './option-item' + +export type SelectableColumnType = 'option' | 'multiOption' + +export interface CreateSelectableMenuResult { + nodes: CheckboxItemDef[] +} + +interface CreateSelectableMenuInternalParams { + column: Column | Column + layer: ViewLayer + filter?: FilterModel<'option'> | FilterModel<'multiOption'> + locale?: Locale + strategy?: FilterStrategy +} + +// ============================================================================ +// Shared helper: ColumnOption[] → CheckboxItemDef[] +// ============================================================================ + +/** + * Converts an array of ColumnOption objects into CheckboxItemDef nodes + * for use with the dropdown-menu Data-First API. + * + * This is the shared conversion logic used by both the synchronous path + * (createSelectableMenuInternal) and the async path (AsyncOptionsNodeLoader). + */ +export function optionsToCheckboxItemDefs( + options: ColumnOption[], + column: Column | Column, + layer: ViewLayer, + filter?: FilterModel<'option'> | FilterModel<'multiOption'>, + counts?: Map, +): CheckboxItemDef[] { + return options.map((option) => { + const isCurrentlySelected = + filter?.values.includes(option.value as string) ?? false + const optionData: ColumnOptionExtended = { + value: option.value as string, + label: option.label, + icon: option.icon, + count: option.count ?? counts?.get(option.value as string) ?? 0, + } + + return { + kind: 'checkbox-item' as const, + id: option.value as string, + value: option.label, + keywords: [option.value as string, option.label], + checked: isCurrentlySelected, + onCheckedChange: (checked: boolean) => { + if (checked) { + layer.addFilterValue(column as Column, [ + option.value as string, + ]) + } else { + layer.removeFilterValue(column as Column, [ + option.value as string, + ]) + } + }, + closeOnClick: false, + render: createOptionItemRenderer(optionData), + } satisfies CheckboxItemDef + }) +} + +// ============================================================================ +// Internal: sync implementation using optionsToCheckboxItemDefs +// ============================================================================ + +/** + * Internal implementation for creating selectable menu nodes. + * Used by both createOptionMenu and createMultiOptionMenu. + */ +function createSelectableMenuInternal({ + column, + layer, + filter, +}: CreateSelectableMenuInternalParams): CreateSelectableMenuResult { + const counts = column.getFacetedUniqueValues() + const options = column.getOptions() + const nodes = optionsToCheckboxItemDefs( + options, + column, + layer, + filter, + counts, + ) + return { nodes } +} + +// ============================================================================ +// Public API - Type-safe wrappers +// ============================================================================ + +export interface CreateOptionMenuProps { + column: Column + layer: ViewLayer + filter?: FilterModel<'option'> + locale?: Locale + strategy?: FilterStrategy +} + +export interface CreateMultiOptionMenuProps { + column: Column + layer: ViewLayer + filter?: FilterModel<'multiOption'> + locale?: Locale + strategy?: FilterStrategy +} + +export type CreateOptionMenuResult = CreateSelectableMenuResult +export type CreateMultiOptionMenuResult = CreateSelectableMenuResult + +/** + * Creates option menu nodes for filter values. + * Returns CheckboxItemDef[] for use with the Data-First API. + */ +export function createOptionMenu({ + column, + layer, + filter, + locale, + strategy, +}: CreateOptionMenuProps): CreateOptionMenuResult { + return createSelectableMenuInternal({ + column: column as unknown as Column, + layer, + filter, + locale, + strategy, + }) +} + +/** + * Creates multiOption menu nodes for filter values. + * Returns CheckboxItemDef[] for use with the Data-First API. + */ +export function createMultiOptionMenu({ + column, + layer, + filter, + locale, + strategy, +}: CreateMultiOptionMenuProps): CreateMultiOptionMenuResult { + return createSelectableMenuInternal({ + column: column as unknown as Column, + layer, + filter, + locale, + strategy, + }) +} + +/** + * Unified function for creating selectable menu nodes. + * Can be used when the column type is dynamically determined. + */ +export function createSelectableMenu( + props: CreateOptionMenuProps | CreateMultiOptionMenuProps, +): CreateSelectableMenuResult { + return createSelectableMenuInternal({ + column: props.column as unknown as Column, + layer: props.layer, + filter: props.filter, + locale: props.locale, + strategy: props.strategy, + }) +} diff --git a/apps/web/registry/ui/data-view/components/value/text-item.tsx b/apps/web/registry/ui/data-view/components/value/text-item.tsx new file mode 100644 index 00000000..26384b6e --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/text-item.tsx @@ -0,0 +1,34 @@ +'use client' + +import type { ItemRenderParams } from '@bazza-ui/react' +import type * as React from 'react' +import { DropdownMenu } from '@/registry/ui/dropdown-menu' +import type { TextFilterItemData } from './text-menu' + +/** + * Renders a text filter item showing the operator and value. + * Used as a render function in ItemDef. + */ +export function renderTextItem( + data: TextFilterItemData, + params: ItemRenderParams, +): React.ReactNode { + const { props } = params + + return ( + + {data.operator} + {data.values[0]} + + ) +} + +/** + * Creates a render function for a text filter item. + * This is used when building ItemDef nodes for text filters. + */ +export function createTextItemRenderer(data: TextFilterItemData) { + return (params: ItemRenderParams): React.ReactNode => { + return renderTextItem(data, params) + } +} diff --git a/apps/web/registry/ui/data-view/components/value/text-menu.tsx b/apps/web/registry/ui/data-view/components/value/text-menu.tsx new file mode 100644 index 00000000..76934270 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/text-menu.tsx @@ -0,0 +1,82 @@ +import type { + Column, + DataViewInstance, + ViewLayer, +} from '@bazza-ui/data-view/react' +import type { ItemDef } from '@bazza-ui/react' +import { createTextItemRenderer } from './text-item' + +/** + * Data structure for text filter menu items. + * Used to display operator and value in the TextItem component. + */ +export interface TextFilterItemData { + operator: string + values: string[] +} + +/** + * Creates text filter items based on the current search query. + * This is called dynamically when the search changes. + */ +export function createTextFilterItems({ + query, + column, + layer, + instance, +}: { + query: string + column: Column + layer: ViewLayer + instance?: DataViewInstance +}): ItemDef[] { + // Only show items when there's a query + if (!query?.trim()) { + return [] + } + + const changeText = (value: string, operator: string) => { + if (instance) { + instance.batch((tx) => { + tx.setFilterValue(column, [String(value)]) + tx.setFilterOperator(column.id, operator) + }) + } else { + layer.setFilterValue(column, [String(value)]) + layer.setFilterOperator(column.id, operator) + } + } + + const containsData: TextFilterItemData = { + operator: 'contains', + values: [query], + } + + const doesNotContainData: TextFilterItemData = { + operator: 'does not contain', + values: [query], + } + + return [ + { + kind: 'item' as const, + id: `${column.id}-text-contains-${query}`, + value: `contains ${query}`, + keywords: [query], + onSelect: () => { + changeText(query, 'contains') + }, + render: createTextItemRenderer(containsData), + } satisfies ItemDef, + { + kind: 'item' as const, + id: `${column.id}-text-does-not-contain-${query}`, + value: `does not contain ${query}`, + keywords: [query], + onSelect: () => { + changeText(query, 'does not contain') + }, + render: createTextItemRenderer(doesNotContainData), + } satisfies ItemDef, + ] +} diff --git a/apps/web/registry/ui/data-view/components/value/types.ts b/apps/web/registry/ui/data-view/components/value/types.ts new file mode 100644 index 00000000..d914dc59 --- /dev/null +++ b/apps/web/registry/ui/data-view/components/value/types.ts @@ -0,0 +1,54 @@ +import type { + Column, + ColumnDataType, + FilterModel, + FilterStrategy, + Locale, + ViewLayer, +} from '@bazza-ui/data-view/react' +import type { DataViewVariant } from '../root/data-view-context' + +export interface FilterValueProps { + filter: FilterModel + column: Column + layer: ViewLayer + strategy: FilterStrategy + locale?: Locale + entityName?: string + className?: string + variant?: DataViewVariant +} + +export interface FilterValueDisplayProps { + filter: FilterModel + column: Column + layer: ViewLayer + locale?: Locale + entityName?: string +} + +export interface FilterValueControllerProps< + TData, + TType extends ColumnDataType, +> { + filter: FilterModel + column: Column + layer: ViewLayer + strategy: FilterStrategy + locale?: Locale +} + +export namespace FilterValue { + export type Props = FilterValueProps< + TData, + TType + > + export type DisplayProps< + TData, + TType extends ColumnDataType, + > = FilterValueDisplayProps + export type ControllerProps< + TData, + TType extends ColumnDataType, + > = FilterValueControllerProps +} diff --git a/apps/web/registry/ui/data-view/components/view/view-switcher.tsx b/apps/web/registry/ui/data-view/components/view/view-switcher.tsx new file mode 100644 index 00000000..b3df12bb --- /dev/null +++ b/apps/web/registry/ui/data-view/components/view/view-switcher.tsx @@ -0,0 +1,96 @@ +'use client' + +import type { BaseViewLayer, DataViewState } from '@bazza-ui/data-view/react' +import { type ComponentPropsWithoutRef, forwardRef } from 'react' +import { cn } from '@/lib/utils' +import { useDataViewContext } from '../root/data-view-context' + +export interface ViewSwitcherProps extends ComponentPropsWithoutRef<'div'> { + /** Array of saved/preset views to display as tabs. */ + views: DataViewState[] + /** Currently active view ID (matched against `view.id`). */ + activeViewId?: string + /** Callback when a view tab is clicked. Defaults to `baseView.load(view)`. */ + onViewSelect?: (view: DataViewState) => void + /** Custom render function for individual tabs. */ + renderTab?: (view: DataViewState, isActive: boolean) => React.ReactNode +} + +/** + * Tab bar for switching between saved/preset views. + * Renders a `
` element with tab buttons. + */ +const ViewSwitcher = forwardRef( + ( + { + views, + activeViewId, + onViewSelect: onViewSelectProp, + renderTab, + className, + ...props + }, + ref, + ) => { + const context = useDataViewContext() + const baseView = context.instance.baseView + + const onViewSelect = + onViewSelectProp ?? + ((view: DataViewState) => { + baseView.load(view) + }) + + return ( +
+ {views.map((view) => { + const isActive = view.id === activeViewId + + if (renderTab) { + return ( + + ) + } + + return ( + + ) + })} +
+ ) + }, +) + +ViewSwitcher.displayName = 'ViewSwitcher' + +export { ViewSwitcher } + +export namespace ViewSwitcher { + export type Props = ViewSwitcherProps +} diff --git a/apps/web/registry/ui/data-view/hooks/index.ts b/apps/web/registry/ui/data-view/hooks/index.ts new file mode 100644 index 00000000..47fac66f --- /dev/null +++ b/apps/web/registry/ui/data-view/hooks/index.ts @@ -0,0 +1,5 @@ +export { + type DebouncedState, + useDebounceCallback, +} from './use-debounce-callback' +export { useUnmount } from './use-unmount' diff --git a/apps/web/registry/ui/data-view/hooks/use-debounce-callback.tsx b/apps/web/registry/ui/data-view/hooks/use-debounce-callback.tsx new file mode 100644 index 00000000..bf8f8c29 --- /dev/null +++ b/apps/web/registry/ui/data-view/hooks/use-debounce-callback.tsx @@ -0,0 +1,67 @@ +/* + * Source: https://usehooks-ts.com/react-hook/use-debounce-callback + */ + +import { useEffect, useMemo, useRef } from 'react' +import { debounce } from '../lib/debounce' +import { useUnmount } from './use-unmount' + +type DebounceOptions = { + leading?: boolean + trailing?: boolean + maxWait?: number +} + +type ControlFunctions = { + cancel: () => void + flush: () => void + isPending: () => boolean +} + +export type DebouncedState ReturnType> = (( + ...args: Parameters +) => ReturnType | undefined) & + ControlFunctions + +export function useDebounceCallback ReturnType>( + func: T, + delay = 500, + options?: DebounceOptions, +): DebouncedState { + const debouncedFunc = useRef>(null) + + useUnmount(() => { + if (debouncedFunc.current) { + debouncedFunc.current.cancel() + } + }) + + const debounced = useMemo(() => { + const debouncedFuncInstance = debounce(func, delay, options) + + const wrappedFunc: DebouncedState = (...args: Parameters) => { + return debouncedFuncInstance(...args) + } + + wrappedFunc.cancel = () => { + debouncedFuncInstance.cancel() + } + + wrappedFunc.isPending = () => { + return !!debouncedFunc.current + } + + wrappedFunc.flush = () => { + return debouncedFuncInstance.flush() + } + + return wrappedFunc + }, [func, delay, options]) + + // Update the debounced function ref whenever func, wait, or options change + useEffect(() => { + debouncedFunc.current = debounce(func, delay, options) + }, [func, delay, options]) + + return debounced +} diff --git a/apps/web/registry/ui/data-view/hooks/use-unmount.tsx b/apps/web/registry/ui/data-view/hooks/use-unmount.tsx new file mode 100644 index 00000000..ecbe0661 --- /dev/null +++ b/apps/web/registry/ui/data-view/hooks/use-unmount.tsx @@ -0,0 +1,18 @@ +/* + * Source: https://usehooks-ts.com/react-hook/use-unmount + */ + +import { useEffect, useRef } from 'react' + +export function useUnmount(func: () => void) { + const funcRef = useRef(func) + + funcRef.current = func + + useEffect( + () => () => { + funcRef.current() + }, + [], + ) +} diff --git a/apps/web/registry/ui/data-view/index.parts.ts b/apps/web/registry/ui/data-view/index.parts.ts new file mode 100644 index 00000000..4568d91f --- /dev/null +++ b/apps/web/registry/ui/data-view/index.parts.ts @@ -0,0 +1,68 @@ +// Compound component parts — import as `import { DataView } from '@/registry/ui/data-view'` +// Usage: , , , etc. + +// ── Filter Actions ─────────────────────────────────────────── +export { FilterActions } from './components/actions/filter-actions' + +// ── Filter Item Components ─────────────────────────────────── +export { + FilterItem, + useFilterItemContext, +} from './components/item/filter-item' +export { FilterOperator } from './components/item/filter-operator' +export { FilterRemove } from './components/item/filter-remove' +export { FilterSubject } from './components/item/filter-subject' +export { FilterValue } from './components/item/filter-value' + +// ── Filter List ────────────────────────────────────────────── +export { FilterList } from './components/list/filter-list' +export { FilterListMobileContainer } from './components/list/filter-list-mobile-container' + +// ── Filter Menu and Trigger ────────────────────────────────── +export { FilterMenu } from './components/menu/filter-menu' +// ── Provider ───────────────────────────────────────────────── +export { DataViewProvider as Provider } from './components/provider/data-view-provider' +// ── Context Exports ────────────────────────────────────────── +export { + DataViewContext as Context, + useDataViewColumn, + useDataViewColumns, + useDataViewContext, + useDataViewEntityName, + useDataViewFilters, + useDataViewInstance, + useDataViewLayer, + useDataViewLocale, + useDataViewSort, + useDataViewStrategy, + useDataViewVariant, +} from './components/root/data-view-context' +// ── Root ───────────────────────────────────────────────────── +export { DataViewRoot as Root } from './components/root/data-view-root' +// ── Sort ───────────────────────────────────────────────────── +export { SortItem } from './components/sort/sort-item' +export { SortMenu } from './components/sort/sort-menu' +export { SortToggle } from './components/sort/sort-toggle' +export { FilterTrigger } from './components/trigger/filter-trigger' +// ── Value Utilities (for advanced usage) ───────────────────── +export { + // Menu creators + createMultiOptionMenu, + // Render functions for custom item rendering + createOptionItemRenderer, + createOptionMenu, + createSelectableMenu, + // Text filter utilities + createTextFilterItems, + createTextItemRenderer, + // Controllers + FilterValueDateController, + FilterValueNumberController, + // Shared editor content components (for custom menu implementations) + OptionEditorContent, + renderOptionItem, + renderTextItem, + TextEditorContent, +} from './components/value' +// ── View ───────────────────────────────────────────────────── +export { ViewSwitcher } from './components/view/view-switcher' diff --git a/apps/web/registry/ui/data-view/index.ts b/apps/web/registry/ui/data-view/index.ts new file mode 100644 index 00000000..6589450c --- /dev/null +++ b/apps/web/registry/ui/data-view/index.ts @@ -0,0 +1,45 @@ +// @bazza-ui/registry-data-view +// Data view filter, sort, and view management primitives. + +// ── Type Exports ───────────────────────────────────────────── +export type { FilterActionsProps } from './components/actions/filter-actions' +export type { + FilterItemContextValue, + FilterItemProps, +} from './components/item/filter-item' +export type { FilterOperatorProps } from './components/item/filter-operator' +export type { FilterRemoveProps } from './components/item/filter-remove' +export type { FilterSubjectProps } from './components/item/filter-subject' +export type { + FilterValueDisplayProps, + FilterValueProps, +} from './components/item/filter-value' +export type { FilterListProps } from './components/list/filter-list' +export type { FilterListMobileContainerProps } from './components/list/filter-list-mobile-container' +export type { FilterMenuProps } from './components/menu/filter-menu' +export type { DataViewProviderProps } from './components/provider/data-view-provider' +export type { + DataViewContextValue, + DataViewVariant, +} from './components/root/data-view-context' +export type { DataViewRootProps } from './components/root/data-view-root' +export type { SortItemProps } from './components/sort/sort-item' +export type { SortMenuProps } from './components/sort/sort-menu' +export type { SortToggleProps } from './components/sort/sort-toggle' +export type { FilterTriggerProps } from './components/trigger/filter-trigger' +// Value editor types +export type { OptionEditorContentProps } from './components/value/editors/option-editor' +export type { TextEditorContentProps } from './components/value/editors/text-editor' +export type { + CreateMultiOptionMenuProps, + CreateMultiOptionMenuResult, + CreateOptionMenuProps, + CreateOptionMenuResult, + CreateSelectableMenuResult, + SelectableColumnType, +} from './components/value/selectable-menu' +export type { FilterValueControllerProps } from './components/value/types' +export type { ViewSwitcherProps } from './components/view/view-switcher' + +// ── Compound Export ────────────────────────────────────────── +export * as DataView from './index.parts' diff --git a/apps/web/registry/ui/data-view/lib/debounce.ts b/apps/web/registry/ui/data-view/lib/debounce.ts new file mode 100644 index 00000000..33ab9ccb --- /dev/null +++ b/apps/web/registry/ui/data-view/lib/debounce.ts @@ -0,0 +1,138 @@ +type ControlFunctions = { + cancel: () => void + flush: () => void + isPending: () => boolean +} + +type DebounceOptions = { + leading?: boolean + trailing?: boolean + maxWait?: number +} + +export function debounce any>( + func: T, + wait: number, + options: DebounceOptions = {}, +): ((...args: Parameters) => ReturnType | undefined) & ControlFunctions { + const { leading = false, trailing = true, maxWait } = options + let timeout: NodeJS.Timeout | null = null + let lastArgs: Parameters | null = null + let lastThis: any + let result: ReturnType | undefined + let lastCallTime: number | null = null + let lastInvokeTime = 0 + + const maxWaitTime = maxWait !== undefined ? Math.max(wait, maxWait) : null + + function invokeFunc(time: number): ReturnType | undefined { + if (lastArgs === null) return undefined + const args = lastArgs + const thisArg = lastThis + lastArgs = null + lastThis = null + lastInvokeTime = time + result = func.apply(thisArg, args) + return result + } + + function shouldInvoke(time: number): boolean { + if (lastCallTime === null) return false + const timeSinceLastCall = time - lastCallTime + const timeSinceLastInvoke = time - lastInvokeTime + return ( + lastCallTime === null || + timeSinceLastCall >= wait || + timeSinceLastCall < 0 || + (maxWaitTime !== null && timeSinceLastInvoke >= maxWaitTime) + ) + } + + function startTimer( + pendingFunc: () => void, + waitTime: number, + ): NodeJS.Timeout { + return setTimeout(pendingFunc, waitTime) + } + + function remainingWait(time: number): number { + if (lastCallTime === null) return wait + const timeSinceLastCall = time - lastCallTime + const timeSinceLastInvoke = time - lastInvokeTime + const timeWaiting = wait - timeSinceLastCall + return maxWaitTime !== null + ? Math.min(timeWaiting, maxWaitTime - timeSinceLastInvoke) + : timeWaiting + } + + function timerExpired() { + const time = Date.now() + if (shouldInvoke(time)) { + return trailingEdge(time) + } + timeout = startTimer(timerExpired, remainingWait(time)) + } + + function leadingEdge(time: number): ReturnType | undefined { + lastInvokeTime = time + timeout = startTimer(timerExpired, wait) + return leading ? invokeFunc(time) : undefined + } + + function trailingEdge(time: number): ReturnType | undefined { + timeout = null + if (trailing && lastArgs) { + return invokeFunc(time) + } + lastArgs = null + lastThis = null + return result + } + + function debounced( + this: any, + ...args: Parameters + ): ReturnType | undefined { + const time = Date.now() + const isInvoking = shouldInvoke(time) + + lastArgs = args + lastThis = this + lastCallTime = time + + if (isInvoking) { + if (timeout === null) { + return leadingEdge(lastCallTime) + } + if (maxWaitTime !== null) { + timeout = startTimer(timerExpired, wait) + return invokeFunc(lastCallTime) + } + } + if (timeout === null) { + timeout = startTimer(timerExpired, wait) + } + return result + } + + debounced.cancel = () => { + if (timeout !== null) { + clearTimeout(timeout) + } + lastInvokeTime = 0 + lastArgs = null + lastThis = null + lastCallTime = null + timeout = null + } + + debounced.flush = () => { + return timeout === null ? result : trailingEdge(Date.now()) + } + + debounced.isPending = () => { + return timeout !== null + } + + return debounced +} diff --git a/apps/web/registry/ui/data-view/package.json b/apps/web/registry/ui/data-view/package.json new file mode 100644 index 00000000..3e002576 --- /dev/null +++ b/apps/web/registry/ui/data-view/package.json @@ -0,0 +1,6 @@ +{ + "name": "@bazza-ui/registry-data-view", + "version": "0.1.0", + "private": true, + "description": "Registry component: Data view filter, sort, and view management primitives" +} diff --git a/apps/web/registry/ui/data-view/ui/debounced-input.tsx b/apps/web/registry/ui/data-view/ui/debounced-input.tsx new file mode 100644 index 00000000..24fdb33b --- /dev/null +++ b/apps/web/registry/ui/data-view/ui/debounced-input.tsx @@ -0,0 +1,39 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Input } from '@/components/ui/input' +import { debounce } from '../lib/debounce' + +export function DebouncedInput({ + value: initialValue, + onChange, + debounceMs = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounceMs?: number +} & Omit, 'onChange'>) { + const [value, setValue] = useState(initialValue) + + // Sync with initialValue when it changes + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + // Define the debounced function with useCallback + const debouncedOnChange = useCallback( + debounce((newValue: string | number) => { + onChange(newValue) + }, debounceMs), + [debounceMs, onChange], + ) + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value + setValue(newValue) // Update local state immediately + debouncedOnChange(newValue) // Call debounced version + } + + return +} diff --git a/apps/web/scripts/seed.ts b/apps/web/scripts/seed.ts new file mode 100644 index 00000000..9573d185 --- /dev/null +++ b/apps/web/scripts/seed.ts @@ -0,0 +1,245 @@ +/** + * Seed script for the server-side data-view example. + * Usage: bun run scripts/seed.ts + * + * Populates the database with: + * - 4 statuses + * - 4 users + * - 20 labels + * - 500 issues (with random labels via pivot table) + */ + +import { drizzle } from 'drizzle-orm/neon-http' +import { nanoid } from 'nanoid' +import * as schema from '../lib/db/schema' + +const db = drizzle(process.env.DATABASE_URL!) + +// ── Reference Data ────────────────────────────────────────── + +const STATUSES = [ + { id: 'backlog', name: 'Backlog', order: 0 }, + { id: 'todo', name: 'Todo', order: 1 }, + { id: 'in-progress', name: 'In Progress', order: 2 }, + { id: 'done', name: 'Done', order: 3 }, +] + +const USERS = [ + { + id: 'u1', + name: 'John Smith', + email: 'john@example.com', + picture: '/avatars/john-smith.png', + }, + { + id: 'u2', + name: 'Rose Eve', + email: 'rose@example.com', + picture: '/avatars/rose-eve.png', + }, + { + id: 'u3', + name: 'Adam Young', + email: 'adam@example.com', + picture: '/avatars/adam-young.png', + }, + { + id: 'u4', + name: 'Michael Scott', + email: 'michael@example.com', + picture: '/avatars/michael-scott.png', + }, +] + +const LABELS = [ + { id: 'l1', name: 'Bug', color: 'red' }, + { id: 'l2', name: 'Enhancement', color: 'green' }, + { id: 'l3', name: 'Task', color: 'blue' }, + { id: 'l4', name: 'Urgent', color: 'pink' }, + { id: 'l5', name: 'Frontend', color: 'orange' }, + { id: 'l6', name: 'Backend', color: 'teal' }, + { id: 'l7', name: 'Performance', color: 'purple' }, + { id: 'l8', name: 'Documentation', color: 'amber' }, + { id: 'l9', name: 'Security', color: 'sky' }, + { id: 'l10', name: 'Testing', color: 'yellow' }, + { id: 'l11', name: 'Refactor', color: 'lime' }, + { id: 'l12', name: 'API', color: 'red' }, + { id: 'l13', name: 'Database', color: 'violet' }, + { id: 'l14', name: 'AI Model', color: 'cyan' }, + { id: 'l15', name: 'Infrastructure', color: 'emerald' }, + { id: 'l16', name: 'Accessibility', color: 'rose' }, + { id: 'l17', name: 'Monitoring', color: 'indigo' }, + { id: 'l18', name: 'Authentication', color: 'fuchsia' }, + { id: 'l19', name: 'Deployment', color: 'green' }, + { id: 'l20', name: 'Feature Request', color: 'orange' }, +] + +// ── Issue Generator ───────────────────────────────────────── + +const VERBS = [ + 'Fix', + 'Add', + 'Improve', + 'Refactor', + 'Update', + 'Remove', + 'Implement', + 'Optimize', + 'Redesign', + 'Revert', +] + +const NOUNS = [ + 'task sidebar', + 'project view', + 'keyboard shortcuts', + 'user permissions', + 'search performance', + 'issue modal', + 'auth flow', + 'API integration', + 'activity feed', + 'notifications', + 'team management', + 'board drag & drop', + 'custom workflows', + 'mobile responsiveness', + 'comment threading', + 'GitHub sync', + 'dark mode', + 'date picker', + 'status badges', + 'workspace settings', +] + +const SUFFIXES = [ + 'in Safari', + 'for enterprise customers', + 'on slow connections', + 'edge case in Firefox', + 'when duplicating issues', + 'for archived projects', + 'in mobile view', + 'on user onboarding', + 'when using keyboard nav', + 'for SSO users', +] + +function pick(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]! +} + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function sample(arr: readonly T[], count: number): T[] { + const shuffled = [...arr].sort(() => 0.5 - Math.random()) + return shuffled.slice(0, count) +} + +function generateTitle(): string { + const verb = pick(VERBS) + const noun = pick(NOUNS) + const suffix = Math.random() < 0.5 ? '' : ` ${pick(SUFFIXES)}` + return `${verb} ${noun}${suffix}` +} + +function daysAgo(days: number): string { + const d = new Date() + d.setDate(d.getDate() - days) + return d.toISOString() +} + +// ── Seed ──────────────────────────────────────────────────── + +async function seed() { + console.log('Seeding database...') + + // Clear existing data (order matters for FK constraints) + console.log(' Clearing existing data...') + await db.delete(schema.issueLabels) + await db.delete(schema.issues) + await db.delete(schema.labels) + await db.delete(schema.users) + await db.delete(schema.statuses) + + // Insert reference data + console.log(' Inserting statuses...') + await db.insert(schema.statuses).values(STATUSES) + + console.log(' Inserting users...') + await db.insert(schema.users).values(USERS) + + console.log(' Inserting labels...') + await db.insert(schema.labels).values(LABELS) + + // Generate issues + const ISSUE_COUNT = 500 + console.log(` Generating ${ISSUE_COUNT} issues...`) + + const issueRows: (typeof schema.issues.$inferInsert)[] = [] + const issueLabelRows: (typeof schema.issueLabels.$inferInsert)[] = [] + + for (let i = 0; i < ISSUE_COUNT; i++) { + const id = nanoid() + const statusId = pick(STATUSES).id + const assigneeId = Math.random() > 0.3 ? pick(USERS).id : null + const estimatedHours = randomInt(1, 16) + const startDate = statusId === 'backlog' ? null : daysAgo(randomInt(1, 90)) + const isUrgent = Math.random() > 0.85 + const priority = randomInt(0, 4) + + issueRows.push({ + id, + title: generateTitle(), + description: + Math.random() > 0.5 ? `Description for issue ${i + 1}` : null, + statusId, + assigneeId, + priority, + estimatedHours, + startDate, + isUrgent, + createdAt: daysAgo(randomInt(0, 120)), + }) + + // Assign 0-3 random labels + const labelCount = randomInt(0, 3) + if (labelCount > 0) { + const selectedLabels = sample(LABELS, labelCount) + for (const label of selectedLabels) { + issueLabelRows.push({ + issueId: id, + labelId: label.id, + }) + } + } + } + + // Batch insert issues (Neon has query size limits, batch in groups) + const BATCH_SIZE = 100 + for (let i = 0; i < issueRows.length; i += BATCH_SIZE) { + const batch = issueRows.slice(i, i + BATCH_SIZE) + await db.insert(schema.issues).values(batch) + console.log( + ` Inserted issues ${i + 1}-${Math.min(i + BATCH_SIZE, issueRows.length)}`, + ) + } + + // Batch insert issue-label associations + if (issueLabelRows.length > 0) { + for (let i = 0; i < issueLabelRows.length; i += BATCH_SIZE) { + const batch = issueLabelRows.slice(i, i + BATCH_SIZE) + await db.insert(schema.issueLabels).values(batch) + } + console.log(` Inserted ${issueLabelRows.length} issue-label associations`) + } + + console.log('Done! Database seeded successfully.') +} + +seed().catch((err) => { + console.error('Seed failed:', err) + process.exit(1) +}) diff --git a/bun.lock b/bun.lock index d47671c2..ca17a257 100644 --- a/bun.lock +++ b/bun.lock @@ -38,12 +38,14 @@ "version": "0.0.0", "dependencies": { "@base-ui/react": "^1.0.0", + "@bazza-ui/data-view": "workspace:*", "@bazza-ui/filters": "workspace:*", "@bazza-ui/react": "workspace:*", "@hookform/resolvers": "^5.2.2", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@ndaidong/txtgen": "^4.0.1", + "@neondatabase/serverless": "^1.0.2", "@next/mdx": "16.1.1", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.3.1", @@ -71,6 +73,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.1", "fumadocs-core": "^16.1.0", "fumadocs-mdx": "^14.0.3", "fumadocs-ui": "^16.1.0", @@ -79,7 +82,7 @@ "lucide-react": "^0.555.0", "motion": "^12.23.24", "nanoid": "^5.1.5", - "next": "16.1.1", + "next": "16.1.6", "next-themes": "^0.4.6", "nuqs": "^2.4.1", "react": "19.2.3", @@ -116,6 +119,7 @@ "@types/react-virtualized": "^9.22.3", "@types/unist": "^3.0.3", "chokidar": "^4.0.3", + "drizzle-kit": "^0.31.8", "husky": "^9.1.7", "jsdom": "^26.0.0", "mdast-util-mdx-jsx": "^3.2.0", @@ -128,134 +132,43 @@ "unified": "^11.0.5", }, }, + "apps/web/registry/ui/data-view": { + "name": "@bazza-ui/registry-data-view", + "version": "0.1.0", + }, "apps/web/registry/ui/filter": { "name": "@bazza-ui/registry-filters", "version": "0.2.0-canary.3", }, - "packages/action-menu": { - "name": "@bazza-ui/action-menu", - "version": "0.1.0-canary.2", - "dependencies": { - "@base-ui-components/react": "^1.0.0-beta.4", - "@radix-ui/primitive": "^1.1.3", - "@radix-ui/react-compose-refs": "^1.1.2", - "@radix-ui/react-portal": "^1.1.10", - "@radix-ui/react-presence": "^1.1.5", - "@radix-ui/react-primitive": "^2.1.4", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "@tanstack/react-virtual": "^3.13.12", - "clsx": "^2.1.1", - "react-virtualized": "^9.22.6", - "remeda": "^2.32.0", - "tailwind-merge": "^3.4.0", - "vaul": "^1.1.2", - "zustand": "^5.0.8", - }, - "devDependencies": { - "@bazza-ui/typescript-config": "*", - "@tanstack/react-query": "^5.62.14", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitest/browser": "^4.0.8", - "@vitest/ui": "4.0.8", - "playwright": "^1.49.1", - "tinybench": "^4.1.0", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^4.0.8", - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0", - }, - }, - "packages/command-menu": { - "name": "@bazza-ui/command-menu", - "version": "0.1.0-canary.5", - "dependencies": { - "@bazza-ui/menu": "workspace:*", - "@bazza-ui/theming": "workspace:*", - "@radix-ui/react-dialog": "^1.1.4", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "@radix-ui/react-visually-hidden": "^1.2.4", - "@tanstack/react-virtual": "^3.13.17", - "zustand": "^5.0.9", - }, - "devDependencies": { - "@bazza-ui/typescript-config": "workspace:*", - "@tanstack/react-query": "^5.62.14", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0", - }, - }, - "packages/context-menu": { - "name": "@bazza-ui/context-menu", - "version": "0.1.0-canary.5", - "dependencies": { - "@base-ui-components/react": "^1.0.0-beta.4", - "@bazza-ui/menu": "workspace:*", - "@bazza-ui/popup-menu": "workspace:*", - "@bazza-ui/theming": "workspace:*", - "@radix-ui/primitive": "^1.1.3", - "@radix-ui/react-compose-refs": "^1.1.2", - "@radix-ui/react-primitive": "^2.1.4", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "clsx": "^2.1.1", - "zustand": "^5.0.8", - }, - "devDependencies": { - "@bazza-ui/typescript-config": "workspace:*", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitest/ui": "4.0.8", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vitest": "^4.0.8", - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0", - }, - }, - "packages/dropdown-menu": { - "name": "@bazza-ui/dropdown-menu", - "version": "0.1.0-canary.5", - "dependencies": { - "@base-ui-components/react": "^1.0.0-beta.4", - "@bazza-ui/menu": "workspace:*", - "@bazza-ui/popup-menu": "workspace:*", - "@bazza-ui/theming": "workspace:*", - "@radix-ui/primitive": "^1.1.3", - "@radix-ui/react-compose-refs": "^1.1.2", - "@radix-ui/react-primitive": "^2.1.4", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "clsx": "^2.1.1", - "zustand": "^5.0.8", - }, + "packages/data-view": { + "name": "@bazza-ui/data-view", + "version": "0.0.0", "devDependencies": { "@bazza-ui/typescript-config": "workspace:*", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", - "@vitest/ui": "4.0.8", - "tsup": "^8.5.0", + "@vitejs/plugin-react": "^5.1.2", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.1", + "jsdom": "^27.4.0", + "tsup": "^8.5.1", "typescript": "^5.9.3", - "vitest": "^4.0.8", + "vitest": "^4.0.17", }, "peerDependencies": { + "drizzle-orm": ">=0.30.0", "react": ">=18.0.0", - "react-dom": ">=18.0.0", }, + "optionalPeers": [ + "drizzle-orm", + "react", + ], }, "packages/filters": { "name": "@bazza-ui/filters", - "version": "0.4.0-canary.4", + "version": "0.0.0-20260205122308", "devDependencies": { "@bazza-ui/typescript-config": "*", "@ndaidong/txtgen": "^4.0.1", @@ -280,90 +193,6 @@ "react-dom": ">=18.0.0", }, }, - "packages/loaders": { - "name": "@bazza-ui/loaders", - "version": "0.1.0-canary.2", - "devDependencies": { - "@apollo/client": "^3.11.11", - "@bazza-ui/typescript-config": "workspace:*", - "@tanstack/react-query": "^5.62.14", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitest/ui": "4.0.8", - "swr": "^2.2.5", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vitest": "^4.0.8", - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0", - }, - }, - "packages/menu": { - "name": "@bazza-ui/menu", - "version": "0.1.0-canary.4", - "dependencies": { - "@bazza-ui/loaders": "workspace:*", - "@bazza-ui/theming": "workspace:*", - "@radix-ui/react-compose-refs": "^1.1.2", - "@radix-ui/react-primitive": "^2.1.4", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "@tanstack/react-virtual": "^3.13.12", - "clsx": "^2.1.1", - "remeda": "^2.32.0", - "tailwind-merge": "^3.4.0", - "zustand": "^5.0.8", - }, - "devDependencies": { - "@bazza-ui/typescript-config": "workspace:*", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitejs/plugin-react": "^5.1.1", - "@vitest/ui": "4.0.8", - "jsdom": "^27.2.0", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vitest": "^4.0.8", - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0", - }, - }, - "packages/popup-menu": { - "name": "@bazza-ui/popup-menu", - "version": "0.1.0-canary.5", - "dependencies": { - "@base-ui-components/react": "^1.0.0-beta.4", - "@bazza-ui/menu": "workspace:*", - "@bazza-ui/theming": "workspace:*", - "@radix-ui/primitive": "^1.1.3", - "@radix-ui/react-compose-refs": "^1.1.2", - "@radix-ui/react-portal": "^1.1.4", - "@radix-ui/react-primitive": "^2.1.4", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "@tanstack/react-virtual": "^3.11.0", - "clsx": "^2.1.1", - "zustand": "^5.0.8", - }, - "devDependencies": { - "@bazza-ui/typescript-config": "workspace:*", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitest/ui": "4.0.8", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vitest": "^4.0.8", - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0", - }, - }, "packages/react": { "name": "@bazza-ui/react", "version": "0.1.0-canary.0", @@ -391,54 +220,6 @@ "react-dom": ">=18.0.0", }, }, - "packages/select": { - "name": "@bazza-ui/select", - "version": "0.1.0-canary.6", - "dependencies": { - "@base-ui-components/react": "^1.0.0-beta.4", - "@bazza-ui/menu": "workspace:*", - "@bazza-ui/popup-menu": "workspace:*", - "@bazza-ui/theming": "workspace:*", - "@radix-ui/primitive": "^1.1.3", - "@radix-ui/react-compose-refs": "^1.1.2", - "@radix-ui/react-primitive": "^2.1.4", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "clsx": "^2.1.1", - "zustand": "^5.0.8", - }, - "devDependencies": { - "@bazza-ui/typescript-config": "workspace:*", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitest/ui": "4.0.8", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vitest": "^4.0.8", - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0", - }, - }, - "packages/theming": { - "name": "@bazza-ui/theming", - "version": "0.1.0-canary.2", - "dependencies": { - "@radix-ui/primitive": "^1.1.3", - "@radix-ui/react-compose-refs": "^1.1.2", - "clsx": "^2.1.1", - "tailwind-merge": "^3.4.0", - }, - "devDependencies": { - "@bazza-ui/typescript-config": "workspace:*", - "@types/react": "^19.1.2", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - }, - "peerDependencies": { - "react": ">=18.0.0", - }, - }, "packages/typescript-config": { "name": "@bazza-ui/typescript-config", "version": "0.0.0", @@ -456,8 +237,6 @@ "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], - "@apollo/client": ["@apollo/client@3.14.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@wry/caches": "^1.0.0", "@wry/equality": "^0.5.6", "@wry/trie": "^0.5.0", "graphql-tag": "^2.12.6", "hoist-non-react-statics": "^3.3.2", "optimism": "^0.18.0", "prop-types": "^15.7.2", "rehackt": "^0.1.0", "symbol-observable": "^4.0.0", "ts-invariant": "^0.10.3", "tslib": "^2.3.0", "zen-observable-ts": "^1.2.5" }, "peerDependencies": { "graphql": "^15.0.0 || ^16.0.0", "graphql-ws": "^5.5.5 || ^6.0.3", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" }, "optionalPeers": ["graphql-ws", "react", "react-dom", "subscriptions-transport-ws"] }, "sha512-0YQKKRIxiMlIou+SekQqdCo0ZTHxOcES+K8vKB53cIDpwABNR0P0yRzPgsbgcj3zRJniD93S/ontsnZsCLZrxQ=="], - "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="], "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.6", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.4" } }, "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg=="], @@ -526,37 +305,19 @@ "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], - "@base-ui-components/react": ["@base-ui-components/react@1.0.0-rc.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@base-ui-components/utils": "0.2.2", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "tabbable": "^6.3.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-9lhUFbJcbXvc9KulLev1WTFxS/alJRBWDH/ibKSQaNvmDwMFS2gKp1sTeeldYSfKuS/KC1w2MZutc0wHu2hRHQ=="], - - "@base-ui-components/utils": ["@base-ui-components/utils@0.2.2", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-rNJCD6TFy3OSRDKVHJDzLpxO3esTV1/drRtWNUpe7rCpPN9HZVHUCuP+6rdDYDGWfXnQHbqi05xOyRP2iZAlkw=="], - "@base-ui/react": ["@base-ui/react@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@base-ui/utils": "0.2.3", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "tabbable": "^6.3.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-4USBWz++DUSLTuIYpbYkSgy1F9ZmNG9S/lXvlUN6qMK0P0RlW+6eQmDUB4DgZ7HVvtXl4pvi4z5J2fv6Z3+9hg=="], "@base-ui/utils": ["@base-ui/utils@0.2.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-/CguQ2PDaOzeVOkllQR8nocJ0FFIDqsWIcURsVmm53QGo8NhFNpePjNlyPIB41luxfOqnG7PU0xicMEw3ls7XQ=="], - "@bazza-ui/action-menu": ["@bazza-ui/action-menu@workspace:packages/action-menu"], - - "@bazza-ui/command-menu": ["@bazza-ui/command-menu@workspace:packages/command-menu"], - - "@bazza-ui/context-menu": ["@bazza-ui/context-menu@workspace:packages/context-menu"], - - "@bazza-ui/dropdown-menu": ["@bazza-ui/dropdown-menu@workspace:packages/dropdown-menu"], + "@bazza-ui/data-view": ["@bazza-ui/data-view@workspace:packages/data-view"], "@bazza-ui/filters": ["@bazza-ui/filters@workspace:packages/filters"], - "@bazza-ui/loaders": ["@bazza-ui/loaders@workspace:packages/loaders"], - - "@bazza-ui/menu": ["@bazza-ui/menu@workspace:packages/menu"], - - "@bazza-ui/popup-menu": ["@bazza-ui/popup-menu@workspace:packages/popup-menu"], - "@bazza-ui/react": ["@bazza-ui/react@workspace:packages/react"], - "@bazza-ui/registry-filters": ["@bazza-ui/registry-filters@workspace:apps/web/registry/ui/filter"], - - "@bazza-ui/select": ["@bazza-ui/select@workspace:packages/select"], + "@bazza-ui/registry-data-view": ["@bazza-ui/registry-data-view@workspace:apps/web/registry/ui/data-view"], - "@bazza-ui/theming": ["@bazza-ui/theming@workspace:packages/theming"], + "@bazza-ui/registry-filters": ["@bazza-ui/registry-filters@workspace:apps/web/registry/ui/filter"], "@bazza-ui/typescript-config": ["@bazza-ui/typescript-config@workspace:packages/typescript-config"], @@ -634,10 +395,16 @@ "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.51.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-fqcQxcxC4LOaUlW8IkyWw8x0yirlLUkbxohz9OnWvVWjf73J5yyw7jxWnkOJaUKXZotcGEScDox9MU6rSkcDgg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], @@ -702,8 +469,6 @@ "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], - "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], - "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], @@ -782,7 +547,7 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], @@ -800,6 +565,8 @@ "@ndaidong/txtgen": ["@ndaidong/txtgen@4.0.1", "", {}, "sha512-z0I53ozZXEhjDKYl/1CEdrunOw/Ck15dJH+ciKgIKxb5atf/bGilxws8G9nF1iKMfb8rieCE03cHud7odMGEKg=="], + "@neondatabase/serverless": ["@neondatabase/serverless@1.0.2", "", { "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" } }, "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw=="], + "@next/env": ["@next/env@16.0.10", "", {}, "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang=="], "@next/mdx": ["@next/mdx@16.1.1", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-XvlZ28/K7kXb1vgTeZWHjjfxDx9BVz/s1bbVlsFOvPfYuSVRmlUkhaiyJTA/7mm9OdpeC57+uHR6k1fUcn5AaA=="], @@ -862,7 +629,7 @@ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], @@ -890,7 +657,7 @@ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.10", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w=="], + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], @@ -932,11 +699,11 @@ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg=="], + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.1.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ=="], "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.39.0", "", { "os": "android", "cpu": "arm" }, "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA=="], @@ -1112,6 +879,8 @@ "@types/parse-path": ["@types/parse-path@7.1.0", "", { "dependencies": { "parse-path": "*" } }, "sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q=="], + "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], @@ -1126,7 +895,7 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], "@vitest/browser": ["@vitest/browser@4.0.8", "", { "dependencies": { "@vitest/mocker": "4.0.8", "@vitest/utils": "4.0.8", "magic-string": "^0.30.21", "pixelmatch": "7.1.0", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.18.3" }, "peerDependencies": { "vitest": "4.0.8" } }, "sha512-oG6QJAR0d7S5SDnIYZwjxCj/a5fhbp9ZE7GtMgZn+yCUf4CxtqbBV6aXyg0qmn8nbUWT+rGuXL2ZB6qDBUjv/A=="], @@ -1134,7 +903,7 @@ "@vitest/expect": ["@vitest/expect@4.0.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.17", "@vitest/utils": "4.0.17", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ=="], - "@vitest/mocker": ["@vitest/mocker@4.0.8", "", { "dependencies": { "@vitest/spy": "4.0.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg=="], + "@vitest/mocker": ["@vitest/mocker@4.0.17", "", { "dependencies": { "@vitest/spy": "4.0.17", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ=="], "@vitest/pretty-format": ["@vitest/pretty-format@4.0.17", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw=="], @@ -1146,15 +915,7 @@ "@vitest/ui": ["@vitest/ui@4.0.8", "", { "dependencies": { "@vitest/utils": "4.0.8", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.8" } }, "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA=="], - "@vitest/utils": ["@vitest/utils@4.0.8", "", { "dependencies": { "@vitest/pretty-format": "4.0.8", "tinyrainbow": "^3.0.3" } }, "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow=="], - - "@wry/caches": ["@wry/caches@1.0.1", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA=="], - - "@wry/context": ["@wry/context@0.7.4", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ=="], - - "@wry/equality": ["@wry/equality@0.5.7", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw=="], - - "@wry/trie": ["@wry/trie@0.5.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA=="], + "@vitest/utils": ["@vitest/utils@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "tinyrainbow": "^3.0.3" } }, "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -1218,6 +979,8 @@ "browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1356,12 +1119,16 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], - "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], + "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], + + "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -1404,6 +1171,8 @@ "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1460,7 +1229,7 @@ "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], @@ -1490,7 +1259,7 @@ "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fumadocs-core": ["fumadocs-core@16.1.0", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.6.2", "@orama/orama": "^3.1.16", "@shikijs/rehype": "^3.15.0", "@shikijs/transformers": "^3.15.0", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "path-to-regexp": "^8.3.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.15.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@mixedbread/sdk": "^0.19.0", "@orama/core": "1.x.x", "@tanstack/react-router": "1.x.x", "@types/react": "*", "algoliasearch": "5.x.x", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0" }, "optionalPeers": ["@mixedbread/sdk", "@orama/core", "@tanstack/react-router", "@types/react", "algoliasearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku"] }, "sha512-5pbO2bOGc/xlb2yLQSy6Oag8mvD5CNf5HzQIG80HjZzLXYWEOHW8yovRKnWKRF9gAibn6WHnbssj3YPAlitV/A=="], @@ -1540,9 +1309,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphql": ["graphql@16.10.0", "", {}, "sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ=="], - - "graphql-tag": ["graphql-tag@2.12.6", "", { "dependencies": { "tslib": "^2.1.0" }, "peerDependencies": { "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg=="], + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], @@ -1572,8 +1339,6 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], - "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1946,8 +1711,6 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], - "optimism": ["optimism@0.18.1", "", { "dependencies": { "@wry/caches": "^1.0.0", "@wry/context": "^0.7.0", "@wry/trie": "^0.5.0", "tslib": "^2.3.0" } }, "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ=="], - "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], @@ -2000,6 +1763,12 @@ "pathval": ["pathval@2.0.0", "", {}, "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -2030,6 +1799,14 @@ "postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -2066,7 +1843,7 @@ "react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="], @@ -2074,7 +1851,7 @@ "react-medium-image-zoom": ["react-medium-image-zoom@5.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BsE+EnFVQzFIlyuuQrZ9iTwyKpKkqdFZV1ImEQN573QPqGrIUuNni7aF+sZwDcxlsuOMayCr6oO/PZR/yJnbRg=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], @@ -2108,8 +1885,6 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - "rehackt": ["rehackt@0.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "*" }, "optionalPeers": ["@types/react", "react"] }, "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw=="], - "rehype-callouts": ["rehype-callouts@2.1.2", "", { "dependencies": { "@types/hast": "^3.0.4", "hast-util-from-html": "^2.0.3", "hast-util-is-element": "^3.0.0", "hastscript": "^9.0.1", "unist-util-visit": "^5.0.0" } }, "sha512-ZZWZ6EknUHiSzr4pQ88C7db3su4DElfJRmphZJbXpDdwW3urTwlYZpHckoC9pjEvBmUEEiJAM0uuc2uxyLdTfg=="], "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], @@ -2210,6 +1985,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], @@ -2264,8 +2041,6 @@ "swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="], - "symbol-observable": ["symbol-observable@4.0.0", "", {}, "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ=="], - "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="], @@ -2332,8 +2107,6 @@ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], - "ts-invariant": ["ts-invariant@0.10.3", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ=="], - "ts-morph": ["ts-morph@27.0.0", "", { "dependencies": { "@ts-morph/common": "~0.28.0", "code-block-writer": "^13.0.3" } }, "sha512-xcqelpTR5PCuZMs54qp9DE3t7tPgA2v/P1/qdW4ke5b3Y5liTGTYj6a/twT35EQW/H5okRqp1UOqwNlgg0K0eQ=="], "tsconfck": ["tsconfck@3.1.5", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg=="], @@ -2466,6 +2239,8 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -2480,16 +2255,10 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.2", "", {}, "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA=="], - "zen-observable": ["zen-observable@0.8.15", "", {}, "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ=="], - - "zen-observable-ts": ["zen-observable-ts@1.2.5", "", { "dependencies": { "zen-observable": "0.8.15" } }, "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg=="], - "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], - "zustand": ["zustand@5.0.9", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@antfu/ni/package-manager-detector": ["package-manager-detector@1.5.0", "", {}, "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw=="], @@ -2506,8 +2275,6 @@ "@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@babel/helper-compilation-targets/browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2518,34 +2285,16 @@ "@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@bazza-ui/action-menu/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@bazza-ui/command-menu/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@bazza-ui/data-view/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@bazza-ui/context-menu/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@bazza-ui/dropdown-menu/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@bazza-ui/filters/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@bazza-ui/filters/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@bazza-ui/filters/vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], - "@bazza-ui/loaders/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@bazza-ui/menu/@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], - - "@bazza-ui/menu/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@bazza-ui/popup-menu/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@bazza-ui/react/@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], - "@bazza-ui/react/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@bazza-ui/select/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "@bazza-ui/theming/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "@changesets/get-github-info/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -2560,8 +2309,12 @@ "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "@dotenvx/dotenvx/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "@dotenvx/dotenvx/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@inquirer/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -2576,7 +2329,9 @@ "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], @@ -2594,25 +2349,33 @@ "@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@neondatabase/serverless/@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="], + "@next/mdx/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], "@radix-ui/react-accordion/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-accordion/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-collapsible/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="], - "@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -2622,11 +2385,13 @@ "@radix-ui/react-dropdown-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.2", "", { "dependencies": { "@radix-ui/react-slot": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw=="], "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-hover-card/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-hover-card/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -2634,6 +2399,8 @@ "@radix-ui/react-menu/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.6", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PbhRFK4lIEw9ADonj48tiYWzkllz81TM7KVYyyMMw2cwHO7D5h4XKEblL8NlaRisTK3QTe6tBEhDccFUryxHBQ=="], + "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.9", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ=="], "@radix-ui/react-menu/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], @@ -2654,11 +2421,13 @@ "@radix-ui/react-navigation-menu/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], - "@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -2666,16 +2435,26 @@ "@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="], + "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-roving-focus/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-select/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-select/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.0.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q=="], "@radix-ui/react-select/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], @@ -2692,21 +2471,25 @@ "@radix-ui/react-select/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg=="], - "@radix-ui/react-select/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.1.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ=="], - "@radix-ui/react-slider/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-switch/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g=="], "@radix-ui/react-switch/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg=="], + "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-tooltip/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -2714,6 +2497,8 @@ "@radix-ui/react-tooltip/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g=="], + "@secretlint/core/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "@shikijs/rehype/@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="], @@ -2730,7 +2515,7 @@ "@testing-library/dom/@babel/runtime": ["@babel/runtime@7.27.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw=="], - "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "@ts-morph/common/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], @@ -2746,24 +2531,26 @@ "@types/babel__traverse/@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="], + "@types/pg/@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="], + "@types/react-virtualized/@types/react": ["@types/react@19.1.1", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ=="], + "@vitest/browser/@vitest/mocker": ["@vitest/mocker@4.0.8", "", { "dependencies": { "@vitest/spy": "4.0.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg=="], + + "@vitest/browser/@vitest/utils": ["@vitest/utils@4.0.8", "", { "dependencies": { "@vitest/pretty-format": "4.0.8", "tinyrainbow": "^3.0.3" } }, "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow=="], + "@vitest/browser/vitest": ["vitest@4.0.8", "", { "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", "@vitest/pretty-format": "4.0.8", "@vitest/runner": "4.0.8", "@vitest/snapshot": "4.0.8", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.8", "@vitest/browser-preview": "4.0.8", "@vitest/browser-webdriverio": "4.0.8", "@vitest/ui": "4.0.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg=="], - "@vitest/browser-playwright/vitest": ["vitest@4.0.8", "", { "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", "@vitest/pretty-format": "4.0.8", "@vitest/runner": "4.0.8", "@vitest/snapshot": "4.0.8", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.8", "@vitest/browser-preview": "4.0.8", "@vitest/browser-webdriverio": "4.0.8", "@vitest/ui": "4.0.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg=="], + "@vitest/browser-playwright/@vitest/mocker": ["@vitest/mocker@4.0.8", "", { "dependencies": { "@vitest/spy": "4.0.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "tinyrainbow": "^3.0.3" } }, "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w=="], + "@vitest/browser-playwright/vitest": ["vitest@4.0.8", "", { "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", "@vitest/pretty-format": "4.0.8", "@vitest/runner": "4.0.8", "@vitest/snapshot": "4.0.8", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.8", "@vitest/browser-preview": "4.0.8", "@vitest/browser-webdriverio": "4.0.8", "@vitest/ui": "4.0.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg=="], "@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "@vitest/mocker/@vitest/spy": ["@vitest/spy@4.0.8", "", {}, "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA=="], - - "@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "tinyrainbow": "^3.0.3" } }, "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w=="], + "@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.8", "", { "dependencies": { "@vitest/pretty-format": "4.0.8", "tinyrainbow": "^3.0.3" } }, "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow=="], "@vitest/ui/vitest": ["vitest@4.0.8", "", { "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", "@vitest/pretty-format": "4.0.8", "@vitest/runner": "4.0.8", "@vitest/snapshot": "4.0.8", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.8", "@vitest/browser-preview": "4.0.8", "@vitest/browser-webdriverio": "4.0.8", "@vitest/ui": "4.0.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg=="], - "@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], - "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "body-parser/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], @@ -2790,6 +2577,10 @@ "dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "esbuild-register/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "estree-util-to-js/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -2828,8 +2619,6 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "msw/graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], - "msw/path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "msw/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], @@ -2854,12 +2643,14 @@ "playground-nextjs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-day-picker/react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react-virtualized/@babel/runtime": ["@babel/runtime@7.27.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw=="], @@ -2876,8 +2667,6 @@ "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "router/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "router/path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], @@ -2904,6 +2693,8 @@ "slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -2914,30 +2705,22 @@ "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "tsup/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "tsx/esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="], - "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "vaul/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.8", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.5", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.7", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.1", "@radix-ui/react-slot": "1.2.1", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0qRz1TZRoXh+xzU7nZ38erOVUAUN6sj62O6PY/OAuGztJNwucgfOX8qjsVCjHAZVdkFm8o3JQ5H8FFiK7o+W/w=="], "vite/esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="], - "vite/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], - - "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], "vite-node/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "vite-node/vite": ["vite@6.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw=="], - "vitest/@vitest/mocker": ["@vitest/mocker@4.0.17", "", { "dependencies": { "@vitest/spy": "4.0.17", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ=="], - - "vitest/@vitest/utils": ["@vitest/utils@4.0.17", "", { "dependencies": { "@vitest/pretty-format": "4.0.17", "tinyrainbow": "^3.0.3" } }, "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w=="], - "vitest/tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], @@ -2948,7 +2731,7 @@ "web/lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="], - "web/next": ["next@16.1.1", "", { "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.1", "@next/swc-darwin-x64": "16.1.1", "@next/swc-linux-arm64-gnu": "16.1.1", "@next/swc-linux-arm64-musl": "16.1.1", "@next/swc-linux-x64-gnu": "16.1.1", "@next/swc-linux-x64-musl": "16.1.1", "@next/swc-win32-arm64-msvc": "16.1.1", "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w=="], + "web/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], "web/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -2972,6 +2755,10 @@ "@babel/helper-compilation-targets/browserslist/update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + "@bazza-ui/filters/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@bazza-ui/filters/@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "@bazza-ui/filters/vitest/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], "@bazza-ui/filters/vitest/@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], @@ -3002,14 +2789,6 @@ "@bazza-ui/filters/vitest/vite": ["vite@6.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", "rollup": "^4.30.1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw=="], - "@bazza-ui/menu/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], - - "@bazza-ui/menu/@vitejs/plugin-react/react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - - "@bazza-ui/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], - - "@bazza-ui/react/@vitejs/plugin-react/react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "@changesets/get-github-info/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "@changesets/parse/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -3028,6 +2807,50 @@ "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@inquirer/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -3040,6 +2863,8 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + "@jridgewell/remapping/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@manypkg/get-packages/globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "@manypkg/get-packages/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -3074,6 +2899,8 @@ "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-roving-focus/@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -3092,6 +2919,8 @@ "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="], + "@shikijs/rehype/shiki/@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="], "@shikijs/rehype/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="], @@ -3102,8 +2931,6 @@ "@shikijs/rehype/shiki/@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="], - "@ts-morph/common/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], - "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], @@ -3122,6 +2949,8 @@ "@types/react-virtualized/@types/react/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "@vitest/browser-playwright/@vitest/mocker/@vitest/spy": ["@vitest/spy@4.0.8", "", {}, "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA=="], + "@vitest/browser-playwright/vitest/@vitest/expect": ["@vitest/expect@4.0.8", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "chai": "^6.2.0", "tinyrainbow": "^3.0.3" } }, "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA=="], "@vitest/browser-playwright/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], @@ -3132,10 +2961,16 @@ "@vitest/browser-playwright/vitest/@vitest/spy": ["@vitest/spy@4.0.8", "", {}, "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA=="], + "@vitest/browser-playwright/vitest/@vitest/utils": ["@vitest/utils@4.0.8", "", { "dependencies": { "@vitest/pretty-format": "4.0.8", "tinyrainbow": "^3.0.3" } }, "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow=="], + "@vitest/browser-playwright/vitest/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "@vitest/browser-playwright/vitest/tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "@vitest/browser/@vitest/mocker/@vitest/spy": ["@vitest/spy@4.0.8", "", {}, "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA=="], + + "@vitest/browser/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], + "@vitest/browser/vitest/@vitest/expect": ["@vitest/expect@4.0.8", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "chai": "^6.2.0", "tinyrainbow": "^3.0.3" } }, "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA=="], "@vitest/browser/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], @@ -3150,8 +2985,12 @@ "@vitest/browser/vitest/tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + "@vitest/ui/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], + "@vitest/ui/vitest/@vitest/expect": ["@vitest/expect@4.0.8", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.8", "@vitest/utils": "4.0.8", "chai": "^6.2.0", "tinyrainbow": "^3.0.3" } }, "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA=="], + "@vitest/ui/vitest/@vitest/mocker": ["@vitest/mocker@4.0.8", "", { "dependencies": { "@vitest/spy": "4.0.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg=="], + "@vitest/ui/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.0.8", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg=="], "@vitest/ui/vitest/@vitest/runner": ["@vitest/runner@4.0.8", "", { "dependencies": { "@vitest/utils": "4.0.8", "pathe": "^2.0.3" } }, "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ=="], @@ -3178,6 +3017,8 @@ "cmdk/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "cmdk/@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "cmdk/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.0.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q=="], "cmdk/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], @@ -3196,6 +3037,58 @@ "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "fix-dts-default-cjs-exports/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], "fumadocs-core/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="], @@ -3308,6 +3201,8 @@ "vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "vaul/@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "vaul/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.1", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-md5dYvyWDY6884yKjXZA8c+iezVMW5rkdxGwwZJ/TieN5al6UBI5YQGZzkuHbA45S3WqrfG6YwDBMxk4BqmbuA=="], "vaul/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], @@ -3324,8 +3219,6 @@ "vite-node/vite/esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="], - "vite-node/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.2", "", { "os": "android", "cpu": "arm" }, "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA=="], @@ -3392,23 +3285,23 @@ "web/jsdom/whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], - "web/next/@next/env": ["@next/env@16.1.1", "", {}, "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA=="], + "web/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], - "web/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA=="], + "web/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], - "web/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw=="], + "web/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], - "web/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ=="], + "web/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], - "web/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg=="], + "web/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], - "web/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ=="], + "web/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], - "web/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA=="], + "web/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], - "web/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA=="], + "web/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], - "web/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw=="], + "web/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], "web/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -3424,12 +3317,8 @@ "@bazza-ui/filters/vitest/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - "@bazza-ui/filters/vitest/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], - "@bazza-ui/filters/vitest/vite/esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="], - "@bazza-ui/filters/vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "@changesets/get-github-info/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "@changesets/get-github-info/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/packages/data-view/IMPLEMENTATION_PLAN.md b/packages/data-view/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..b0169817 --- /dev/null +++ b/packages/data-view/IMPLEMENTATION_PLAN.md @@ -0,0 +1,588 @@ +# @bazza-ui/data-view — Implementation Plan + +Headless data view state manager. Manages filters, sort, and named view configurations. +Framework-agnostic core with React bindings via sub-path export. + +## Decisions + +| Decision | Choice | +|---|---| +| Package name | `@bazza-ui/data-view` | +| Dependency on `@bazza-ui/filters` | Standalone fork — no dependency | +| TanStack Table integration | Dropped | +| Builder new method pattern | Immutable clone | +| Filter action parameters | Full `Column` object | +| Sort model | Multi-column + custom sorts, ordered array | +| Batch actions | Unified — mix filter + sort atomically | +| Serialization | Ships with core | +| Builder validation | Runtime only | +| Operator system | Fully customizable — `OperatorSet` with composition API | +| Data types | Extensible — `defineColumnType()` for custom types | +| React separation | Sub-path exports (`@bazza-ui/data-view` core, `@bazza-ui/data-view/react` hook) | +| Icon type in core | `unknown` — no React dependency in core | +| Filter component updates | Separate effort (out of scope) | + +## Naming Reference + +| Concept | Name | +|---|---| +| Core serializable type | `DataViewState` | +| Hook | `useDataView` | +| Hook return | `DataViewInstance` | +| Hook options | `DataViewOptions` | +| Builder factory | `createColumnBuilder` | +| Builder class | `ColumnBuilder` | +| Sort item union | `SortRule` (`ColumnSort \| CustomSort`) | +| Sort array | `SortState` (`SortRule[]`) | +| Operator collection | `OperatorSet` | +| Operator item | `OperatorDefinition` | +| Operator factory | `defineOperators` | +| Column type | `ColumnType` | +| Column type factory | `defineColumnType` | +| Default operators | `textOperators`, `optionOperators`, etc. | +| Default column types | `textType`, `numberType`, etc. | +| Serialization | `serializeView` / `deserializeView` | + +## Package Structure + +``` +src/ + index.ts # Core barrel (framework-agnostic) + core/ + types.ts # All type definitions + operator-set.ts # OperatorSet class + defineOperators + operator-sets.ts # Default operator sets (7 built-in types) + column-types.ts # ColumnType + defineColumnType + 7 built-in types + operators.ts # determineNewOperator + getOperatorSet + filters.ts # Pure filter state operations + sort.ts # Pure sort state operations + view.ts # Pure view state operations + columns/ + index.ts # Column barrel export + column-builder.ts # ColumnBuilder + createColumnBuilder + column-factory.ts # createColumns / createColumn + column-data-service.ts # ColumnDataService + react/ + index.ts # React barrel (re-exports core + hook) + use-data-view.ts # useDataView hook + lib/ + array.ts # Array utilities + helpers.ts # Value creators, type guards, filterRow + i18n.ts # Locale translations + memo.ts # Custom memoization + order-fns.ts # Option ordering functions + type-guards.ts # Column/filter type guards + serialize.ts # serializeView / deserializeView + __tests__/ + operator-set.test.ts # OperatorSet composition + operator-sets.test.ts # Default operator match functions + column-types.test.ts # defineColumnType + custom types + column-builder.test.ts # Builder: .sortable(), .operators(), .custom() + sort.test.ts # Sort state operations + view.test.ts # View state operations + data-view.test.tsx # Hook (comprehensive, jsdom) + filter-operations.test.ts # Filter operations + serialize.test.ts # Serialization round-trips +``` + +## Entry Points + +- `@bazza-ui/data-view` — core (framework-agnostic, no React) +- `@bazza-ui/data-view/react` — re-exports core + `useDataView` hook + +React is an **optional** peer dependency. + +--- + +## Phase 1: Fork Filter Logic from `@bazza-ui/filters` + +> Replicate existing filter functionality under the new namespace. +> All files adapted — no dependency on `@bazza-ui/filters`. + +### Tasks + +- [ ] **1.1** Fork `lib/array.ts` — `uniq`, `take`, `flatten`, `addUniq`, `removeUniq`, `isAnyOf`, `intersection`, `min`, `max`, `minMax` +- [ ] **1.2** Fork `lib/memo.ts` — custom memoization utility +- [ ] **1.3** Fork `lib/helpers.ts` — `getColumn`, `createNumberFilterValue`, `createBigIntFilterValue`, `createDateFilterValue`, `createDateRange`, `createNumberRange`, `createBigIntRange`, `isColumnOption`, `isColumnOptionArray`, `isStringArray`, `isColumnOptionMap`, `isMinMaxTuple`, `getValidNumber`, `isValidNumber`, `getValidBigInt` + - Do NOT port `filterRow`/`filterData` yet — these will be rewritten in Phase 2 to use OperatorSet +- [ ] **1.4** Fork `lib/order-fns.ts` — `orderFns` (`count`, `label`), `applyOrderFns`, type guards +- [ ] **1.5** Fork `lib/type-guards.ts` — `isColumnType`, `isTextColumn`, `isOptionColumn`, etc. + filter type guards +- [ ] **1.6** Fork `lib/i18n.ts` — `Locale` type, `t()` function, locale JSON files +- [ ] **1.7** Fork `core/types.ts` — all existing types, adapted: + - `ColumnDataType` → `BuiltInColumnDataType | (string & {})` (extensible) + - `FilterModel` → `type: string`, `operator: string`, `values: unknown[]` (loosened) + - `ColumnConfig` → add `operators?: OperatorSet`, `sortable?: boolean`, `defaultSortDirection?: SortDirection`, `normalizeValues?`, `columnType?: ColumnType` + - `icon` → `unknown` (not React-specific) + - Add sort types: `SortDirection`, `ColumnSort`, `CustomSort`, `SortRule`, `SortState`, `CustomSortConfig` + - Add `DataViewState` type + - Add new action interfaces: `FilterActions`, `SortActions`, `ViewActions`, `BatchActions` + - Add `DataViewOptions`, `DataViewInstance`, `DataViewStateUpdaterFn` + - Remove `FilterOperators` mapped type (operators are now strings) + - Remove per-type operator union types (`TextFilterOperator`, etc.) +- [ ] **1.8** Fork `core/columns/column-data-service.ts` — `ColumnDataService` class (unchanged) +- [ ] **1.9** Fork `core/columns/column-factory.ts` — `createColumns`, `createColumn` (unchanged) +- [ ] **1.10** Update all import paths to use `.js` extensions (verbatimModuleSyntax) +- [ ] **1.11** Port existing array/order-fns tests +- [ ] **1.12** Verify: `bun run build && bun run type-check && bun run test` + +### Notes + +- `core/operators.ts` (the current one with `DEFAULT_OPERATORS`, `filterTypeOperatorDetails`, `determineNewOperator`) is NOT forked directly — it gets rewritten in Phase 2 +- `core/filters.ts` (the current `filterOperations`) is NOT forked directly — it gets rewritten in Phase 2 +- `lib/filter-fns.ts` is NOT forked — match functions move onto `OperatorDefinition` in Phase 2 +- TanStack Table integration is NOT forked + +--- + +## Phase 2: Operator System + +> Fully customizable operator definitions with composition API. +> Replaces the hard-coded operator constants + per-type filter functions. + +### Types + +```typescript +interface OperatorDefinition { + id: string + label: string + i18nKey?: string + target: 'single' | 'multiple' + match?: (cellValue: any, filterValues: any[]) => boolean + plural?: string // Transition: 1 → 2+ values + singular?: string // Transition: 2+ → 1 values +} + +type OperatorDefinitionInput = Omit + +class OperatorSet { + get(id: TId): OperatorDefinition + all(): OperatorDefinition[] + ids(): TId[] + has(id: string): boolean + getDefault(target: 'single' | 'multiple'): OperatorDefinition + + only(...ids: K[]): OperatorSet + without(...ids: K[]): OperatorSet> + extend(defs: Record): OperatorSet + replace(id: TId, overrides: Partial): OperatorSet + defaults(config: { single?: TId; multiple?: TId }): OperatorSet +} + +function defineOperators( + definitions: Record, + config?: { defaultSingle?: TId; defaultMultiple?: TId } +): OperatorSet +``` + +### Tasks + +- [ ] **2.1** Implement `OperatorSet` class in `core/operator-set.ts` + - Constructor takes `Map` + default config + - All composition methods return new instances (immutable) + - `getDefault()` auto-selects first single/multiple operator if not explicitly set + - Validate `plural`/`singular` references point to operators that exist in the set +- [ ] **2.2** Implement `defineOperators()` factory + - Takes a `Record`, injects `id` from keys + - Optional `defaultSingle`/`defaultMultiple` config + - Returns `OperatorSet` +- [ ] **2.3** Create default operator sets in `core/operator-sets.ts` + - `textOperators`: `contains`, `does not contain` — port match logic from current `textFilterFn` + - `optionOperators`: `is`, `is not`, `is any of`, `is none of` — port from `optionFilterFn` + - `multiOptionOperators`: `include`, `exclude`, `include any of`, `include all of`, `exclude if any of`, `exclude if all` — port from `multiOptionFilterFn` + - `numberOperators`: `is`, `is not`, `is greater than`, `is greater than or equal to`, `is less than`, `is less than or equal to`, `is between`, `is not between` — port from `numberFilterFn` + - `bigIntOperators`: same operators as number, bigint-specific match — port from `bigIntFilterFn` + - `dateOperators`: `is`, `is not`, `is before`, `is on or after`, `is after`, `is on or before`, `is between`, `is not between` — port from `dateFilterFn` + - `booleanOperators`: `is`, `is not` — port from `booleanFilterFn` + - Each operator has `i18nKey`, `target`, `match`, `plural`/`singular` relationships + - Export `defaultOperatorSets: Record` +- [ ] **2.4** Implement `getOperatorSet()` and `determineNewOperator()` in `core/operators.ts` + - `getOperatorSet(column)`: returns `column.operators ?? defaultOperatorSets[column.type]` + - `determineNewOperator(operatorSet, oldVals, nextVals, currentOp)`: reads `plural`/`singular` from the set +- [ ] **2.5** Rewrite `core/filters.ts` — pure filter operations using OperatorSet + - `addFilterValue`: use `getOperatorSet(column).getDefault()` for initial operator + - `setFilterValue`: use `column.normalizeValues?.()` instead of type switch + - `determineNewOperator` uses operator set, not global lookup + - `removeFilterValue`, `setFilterOperator`, `removeFilter`, `removeAllFilters` adapted +- [ ] **2.6** Rewrite `filterRow`/`filterData` in `lib/helpers.ts` + - Use `operatorSet.get(filter.operator).match()` — no per-type switch statement +- [ ] **2.7** Tests: `operator-set.test.ts` + - Construction via `defineOperators` + - `.get()`, `.all()`, `.ids()`, `.has()`, `.getDefault()` + - `.only()`: restricts to specified operators, type narrows + - `.without()`: removes specified operators + - `.extend()`: adds new operators, preserves existing + - `.replace()`: overrides properties of existing operator + - `.defaults()`: changes default single/multiple + - Chaining: `.without(...).extend(...).defaults(...)` + - Error cases: `.get()` with unknown id, `.only()` with invalid id +- [ ] **2.8** Tests: `operator-sets.test.ts` + - Port ALL existing filter function tests as match function tests + - Every default operator's `match` function tested with edge cases + - Verify `plural`/`singular` relationships are consistent + - Verify `target` assignments are correct + - Verify `i18nKey` is set for every operator +- [ ] **2.9** Tests: `filter-operations.test.ts` + - Port relevant tests from `data-table-filters.test.tsx` (filter operations only) + - Test `addFilterValue`, `removeFilterValue`, `setFilterValue`, `setFilterOperator`, `removeFilter`, `removeAllFilters` + - Test operator auto-transitions via `determineNewOperator` + - Test with default operator sets + - Test with custom operator sets +- [ ] **2.10** Verify: `bun run build && bun run type-check && bun run test` + +--- + +## Phase 3: Column Types + Column Builder + +> Extensible data type system + builder with `.sortable()`, `.operators()`, `.custom()`. + +### Types + +```typescript +interface ColumnTypeConfig { + id: string + operators: OperatorSet + normalizeValues?: (values: TValue[]) => TValue[] + serialize?: (value: TValue) => unknown + deserialize?: (raw: unknown) => TValue +} + +interface ColumnType { + id: string + operators: OperatorSet + normalizeValues: (values: TValue[]) => TValue[] + serialize?: (value: TValue) => unknown + deserialize?: (raw: unknown) => TValue +} + +function defineColumnType(config: ColumnTypeConfig): ColumnType +``` + +### Tasks + +- [ ] **3.1** Implement `ColumnType` + `defineColumnType()` in `core/column-types.ts` + - `normalizeValues` defaults to identity function if not provided +- [ ] **3.2** Create 7 built-in column types + - `textType`: operators = `textOperators`, no normalization, no serialization + - `numberType`: operators = `numberOperators`, normalizeValues = number range sorting + - `bigIntType`: operators = `bigIntOperators`, normalizeValues = bigint range sorting, serialize/deserialize for BigInt + - `dateType`: operators = `dateOperators`, normalizeValues = date range sorting, serialize/deserialize for Date + - `booleanType`: operators = `booleanOperators` + - `optionType`: operators = `optionOperators` + - `multiOptionType`: operators = `multiOptionOperators` + - Export `builtInColumnTypes: Record` +- [ ] **3.3** Refactor `ColumnBuilder` (rename from `ColumnConfigBuilder`) + - Rename class: `ColumnConfigBuilder` → `ColumnBuilder` + - Rename factory: `createColumnConfigHelper` → `createColumnBuilder` + - Rename interface: `FluentColumnConfigHelper` → `FluentColumnBuilder` +- [ ] **3.4** Add `.sortable()` method (immutable clone) + - `sortable(options?: { default?: { direction: SortDirection } }): ColumnBuilder<...>` + - Sets `config.sortable = true` + - Optionally sets `config.defaultSortDirection` + - Available on all column types +- [ ] **3.5** Add `.operators()` method (immutable clone) + - `operators(set: OperatorSet): ColumnBuilder<...>` + - Stores `OperatorSet` on `config.operators` + - Available on all column types +- [ ] **3.6** Add `.custom()` on `FluentColumnBuilder` + - `custom(type: ColumnType): ColumnBuilder` + - Sets `config.type = type.id` + - Sets `config.operators = type.operators` + - Sets `config.normalizeValues = type.normalizeValues` + - Sets `config.columnType = type` + - Returns builder with general methods only +- [ ] **3.7** Wire built-in builder methods to set `columnType` + - `.text()` sets `columnType = textType`, `.number()` sets `columnType = numberType`, etc. + - This ensures `normalizeValues` and `serialize`/`deserialize` are automatically set +- [ ] **3.8** Tests: `column-types.test.ts` + - `defineColumnType`: creates ColumnType with correct defaults + - Built-in types: verify operators, normalizeValues, serialize/deserialize + - Custom type: create, verify, use through builder +- [ ] **3.9** Tests: `column-builder.test.ts` + - `.sortable()`: sets config correctly, immutable + - `.sortable({ default: { direction: 'desc' } })`: sets default direction + - `.operators()`: accepts OperatorSet, stores on config, immutable + - `.operators()` override: calling after `.custom()` overrides type's default + - `.custom()`: correct TVal inference, type-specific methods unavailable + - Chaining: `.custom(currencyType).id('price').sortable().operators(customOps).build()` + - Existing methods still work: `.text()`, `.option()`, `.min()`, `.max()`, etc. +- [ ] **3.10** Verify: `bun run build && bun run type-check && bun run test` + +--- + +## Phase 4: Sort + View Operations + +> Pure state operations for sort and view management. + +### Sort Operations (`core/sort.ts`) + +```typescript +const sortOperations = { + toggleColumnSort(sort: SortState, columnId: string): SortState + // Cycle: none → desc → asc → none + // Preserves custom sorts in array + + setCustomSort(sort: SortState, id: string, enabled: boolean): SortState + // enabled: add/update. disabled: remove. + + setSort(newSort: SortState): SortState + // Full replacement + + clearSort(): SortState + // Returns [] +} +``` + +### View Operations (`core/view.ts`) + +```typescript +const viewOperations = { + load(view: DataViewState): DataViewState + snapshot(current: DataViewState, meta?: { id?: string; name?: string }): DataViewState + reset(defaultView?: DataViewState): DataViewState + merge(current: DataViewState, partial: Partial): DataViewState +} +``` + +### Tasks + +- [ ] **4.1** Implement `sortOperations` in `core/sort.ts` + - `toggleColumnSort`: find existing ColumnSort → cycle direction → update/add/remove + - `setCustomSort`: find existing CustomSort → update enabled or add/remove + - `setSort`: identity (return newSort) + - `clearSort`: return `[]` +- [ ] **4.2** Implement `viewOperations` in `core/view.ts` + - `load`: return view as-is + - `snapshot`: spread current + meta, return new object + - `reset`: return defaultView or `{ filters: [], sort: [] }` + - `merge`: replace `filters`/`sort` if provided, preserve if omitted; update `id`/`name` if provided +- [ ] **4.3** Tests: `sort.test.ts` + - `toggleColumnSort`: none → desc, desc → asc, asc → none + - `toggleColumnSort`: preserves custom sorts in array + - `toggleColumnSort`: multi-column (add second column sort) + - `setCustomSort`: add, update, remove + - `setCustomSort`: preserves column sorts + - `setSort`: full replacement + - `clearSort`: returns empty array +- [ ] **4.4** Tests: `view.test.ts` + - `load`: returns provided view + - `snapshot`: captures state with optional metadata + - `reset`: with default view, without default view + - `merge`: filters only, sort only, both, id/name, preserves unspecified + - `merge`: does NOT deep-merge filters array (replaces entirely) +- [ ] **4.5** Verify: `bun run build && bun run type-check && bun run test` + +--- + +## Phase 5: `useDataView` Hook + +> Main React hook managing unified `DataViewState`. +> Lives in `src/react/use-data-view.ts`. + +### Signature + +```typescript +function useDataView( + options: DataViewOptions +): DataViewInstance +``` + +### Internal State + +Single `DataViewState` atom. Filter/sort actions are convenience wrappers over `setView`. + +### Tasks + +- [ ] **5.1** Implement `useDataView` hook + - Internal state: `useState(defaultView ?? { filters: [], sort: [] })` + - Controlled/uncontrolled detection (same pattern as `useDataTableFilters`) + - `onViewChange` handler detection: `length <= 1` → dispatch, `length > 1` → custom handler + - `setView` callback with context support +- [ ] **5.2** Implement `filterActions` on instance + - `addFilterValue`, `removeFilterValue`, `setFilterValue`, `setFilterOperator`, `removeFilter`, `removeAllFilters` + - Each wraps `setView` targeting `view.filters` via `filterOperations` +- [ ] **5.3** Implement `sortActions` on instance + - `setSort`, `toggleColumnSort`, `setCustomSort`, `clearSort` + - Each wraps `setView` targeting `view.sort` via `sortOperations` +- [ ] **5.4** Implement `viewActions` on instance + - `load`, `snapshot`, `reset`, `merge` + - Each uses `viewOperations` +- [ ] **5.5** Implement unified `batch` + - Single `setView` call, accumulates filter + sort operations + - `BatchActions` type includes all filter + sort operations (without context param) +- [ ] **5.6** Column creation + - `useMemo` converting `ColumnConfig[]` → `Column[]` via `createColumns` + - Inject `options`, `faceted` data (same as current hook) + - Inject `operators` from ColumnConfig or default for type + - Inject `normalizeValues` from ColumnConfig or ColumnType +- [ ] **5.7** Implement `createTypedDataView()` factory +- [ ] **5.8** Export from `src/react/index.ts` +- [ ] **5.9** Tests: `data-view.test.tsx` (jsdom environment) + - Uncontrolled mode: default view, all action types + - Controlled mode: dispatch handler + - Controlled mode: custom handler with context + - `filterActions`: all operations, operator auto-transitions + - `sortActions`: toggle cycling, custom sorts, multi-column + - `viewActions.load`: replaces entire state + - `viewActions.snapshot`: captures state + - `viewActions.reset`: with/without default + - `viewActions.merge`: partial updates + - Unified `batch`: atomic filter + sort, single render + - Column creation: operator set from config, default from type + - Custom column types through the hook + - Edge cases: empty state, double-controlled error +- [ ] **5.10** Verify: `bun run build && bun run type-check && bun run test` + +--- + +## Phase 6: Serialization + +> `serializeView` / `deserializeView` with custom type support. + +### Tasks + +- [ ] **6.1** Implement `serializeView` in `lib/serialize.ts` + - Convert `DataViewState` to URL-safe base64 string + - Use `columnTypes` map to call `serialize()` on filter values + - Built-in types: Date → ISO string, BigInt → string + - Handle `encodeURIComponent`/`decodeURIComponent` for URL safety +- [ ] **6.2** Implement `deserializeView` in `lib/serialize.ts` + - Reverse of serialize + - Use `columnTypes` map to call `deserialize()` on filter values + - Built-in types: ISO string → Date, string → BigInt + - Graceful error handling for malformed input +- [ ] **6.3** Export `builtInColumnTypes` map for convenience +- [ ] **6.4** Tests: `serialize.test.ts` + - Round-trip with all built-in filter types + - Date values survive serialization + - BigInt values survive serialization + - Custom column types with serialize/deserialize + - Empty views + - Views with sort state + - Views with id/name metadata + - Malformed input handling +- [ ] **6.5** Verify: `bun run build && bun run type-check && bun run test` + +--- + +## Phase 7: Integration + Polish + +> Final quality checks and cleanup. + +### Tasks + +- [ ] **7.1** Complete barrel exports in `src/index.ts` (core) +- [ ] **7.2** Complete barrel exports in `src/react/index.ts` +- [ ] **7.3** Run `bun run check:fix` (Biome lint + format) +- [ ] **7.4** Run full test suite from monorepo root: `bun run test` +- [ ] **7.5** Run full build from monorepo root: `bun run build` +- [ ] **7.6** Run type-check from monorepo root: `bun run type-check` +- [ ] **7.7** Verify tree-shaking: importing from `@bazza-ui/data-view` does not pull in React +- [ ] **7.8** Add changeset for initial version + +--- + +## Out of Scope + +- `` component updates (separate effort) +- TanStack Table integration +- Documentation site updates +- Migration guide from `@bazza-ui/filters` +- i18n additions for sort-related UI strings + +--- + +## Consumer Usage Examples + +### Basic (React) + +```typescript +import { + useDataView, + createColumnBuilder, + textOperators, +} from '@bazza-ui/data-view/react' + +type Issue = { title: string; status: string; createdAt: Date } + +const cb = createColumnBuilder() + +const columns = [ + cb.text().id('title').accessor((r) => r.title).displayName('Title') + .sortable().build(), + cb.option().id('status').accessor((r) => r.status).displayName('Status') + .options([...]).operators(optionOperators.only('is', 'is any of')).build(), + cb.date().id('createdAt').accessor((r) => r.createdAt).displayName('Created') + .sortable({ default: { direction: 'desc' } }).build(), +] as const + +const instance = useDataView({ + strategy: 'client', + data: issues, + columnsConfig: columns, + defaultView: { + filters: [], + sort: [{ type: 'column', columnId: 'createdAt', direction: 'desc' }], + }, +}) + +// instance.filters, instance.sort, instance.view +// instance.filterActions.addFilterValue(...) +// instance.sortActions.toggleColumnSort('title') +// instance.viewActions.snapshot({ name: 'My saved view' }) +// instance.batch((actions) => { actions.removeAllFilters(); actions.clearSort() }) +``` + +### Custom Operators + +```typescript +import { textOperators } from '@bazza-ui/data-view' + +const extendedTextOps = textOperators.extend({ + 'starts with': { + label: 'starts with', + target: 'single', + match: (cell, [query]) => cell.toLowerCase().startsWith(query.toLowerCase()), + }, +}) + +cb.text().id('name').accessor((r) => r.name).displayName('Name') + .operators(extendedTextOps).build() +``` + +### Custom Data Type + +```typescript +import { defineColumnType, defineOperators } from '@bazza-ui/data-view' + +const currencyType = defineColumnType<{ amount: number; currency: string }>({ + id: 'currency', + operators: defineOperators({ + 'equals': { + label: 'equals', + target: 'single', + match: (cell, [val]) => cell.amount === val.amount && cell.currency === val.currency, + }, + 'is greater than': { + label: 'is greater than', + target: 'single', + match: (cell, [val]) => cell.amount > val.amount, + }, + }), + serialize: (v) => ({ a: v.amount, c: v.currency }), + deserialize: (raw: any) => ({ amount: raw.a, currency: raw.c }), +}) + +cb.custom(currencyType).id('price').accessor((r) => r.price).displayName('Price').build() +``` + +### Server-side (Non-React) + +```typescript +import { filterOperations, sortOperations, serializeView } from '@bazza-ui/data-view' + +// Pure state operations — no React needed +const filters = filterOperations.addFilterValue([], column, ['active']) +const sort = sortOperations.toggleColumnSort([], 'createdAt') +const encoded = serializeView({ filters, sort }) +// Send `encoded` to the server as a query param +``` diff --git a/packages/data-view/package.json b/packages/data-view/package.json new file mode 100644 index 00000000..dc791737 --- /dev/null +++ b/packages/data-view/package.json @@ -0,0 +1,72 @@ +{ + "name": "@bazza-ui/data-view", + "version": "0.0.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js", + "require": "./dist/react/index.cjs" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js", + "require": "./dist/server/index.cjs" + }, + "./drizzle/pg": { + "types": "./dist/drizzle/pg.d.ts", + "import": "./dist/drizzle/pg.js", + "require": "./dist/drizzle/pg.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "tsup --watch", + "build": "tsup", + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest --watch", + "bench": "vitest bench --run", + "bench:watch": "vitest bench --watch" + }, + "peerDependencies": { + "react": ">=18.0.0", + "drizzle-orm": ">=0.30.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "drizzle-orm": { + "optional": true + } + }, + "devDependencies": { + "@bazza-ui/typescript-config": "workspace:*", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^5.1.2", + "date-fns": "^4.1.0", + "drizzle-orm": "^0.45.1", + "jsdom": "^27.4.0", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.17" + } +} diff --git a/packages/data-view/src/__tests__/arrays.test.ts b/packages/data-view/src/__tests__/arrays.test.ts new file mode 100644 index 00000000..8da518a6 --- /dev/null +++ b/packages/data-view/src/__tests__/arrays.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { uniq } from '../lib/array.js' + +describe('lib/array', () => { + describe('uniq', () => { + it('should return an empty array for an empty input', () => { + const data: any[] = [] + expect(uniq(data)).toEqual([]) + }) + + it('should work with referential equality', () => { + const item = { id: 1 } + const data = [item, item] + expect(uniq(data)).toHaveLength(1) + expect(uniq(data)[0]).toBe(item) + }) + + it('should work with deep equality for objects', () => { + const data = [ + { id: 1, name: 'IB Bank' }, + { id: 1, name: 'IB Bank' }, + ] + const uniqueData = uniq(data) + expect(uniqueData).toHaveLength(1) + expect(uniqueData[0]).toEqual({ id: 1, name: 'IB Bank' }) + }) + + it('should work with primitive values', () => { + const numbers = [1, 2, 2, 3, 1, 4] + const uniqueNumbers = uniq(numbers) + expect(uniqueNumbers).toHaveLength(4) + expect(uniqueNumbers).toEqual(expect.arrayContaining([1, 2, 3, 4])) + + const strings = ['a', 'b', 'a', 'c'] + const uniqueStrings = uniq(strings) + expect(uniqueStrings).toHaveLength(3) + expect(uniqueStrings).toEqual(expect.arrayContaining(['a', 'b', 'c'])) + }) + + it('should work with nested arrays', () => { + const data = [ + [1, 2], + [1, 2], + [2, 3], + ] + const uniqueData = uniq(data) + expect(uniqueData).toHaveLength(2) + expect(uniqueData).toEqual( + expect.arrayContaining([ + [1, 2], + [2, 3], + ]), + ) + }) + + it('should work with nested objects and arrays', () => { + const data = [ + { id: 1, arr: [1, 2, { nested: 'a' }] }, + { id: 1, arr: [1, 2, { nested: 'a' }] }, + { id: 2, arr: [3, 4, { nested: 'b' }] }, + ] + const uniqueData = uniq(data) + expect(uniqueData).toHaveLength(2) + expect(uniqueData).toEqual( + expect.arrayContaining([ + { id: 1, arr: [1, 2, { nested: 'a' }] }, + { id: 2, arr: [3, 4, { nested: 'b' }] }, + ]), + ) + }) + + it('should work with a mix of types', () => { + const data = [ + 1, + '1', + { val: 1 }, + { val: '1' }, + [1], + [1], + null, + null, + undefined, + undefined, + ] + const uniqueData = uniq(data) + // The expected length depends on deep equality of all these different types. + expect(uniqueData).toHaveLength(7) + expect(uniqueData).toEqual( + expect.arrayContaining([ + 1, + '1', + { val: 1 }, + { val: '1' }, + [1], + null, + undefined, + ]), + ) + }) + + it('should consider functions equal if their toString representations are equal', () => { + // Warning: using toString for functions is usually not recommended for complex cases. + const func1 = () => 1 + const func2 = () => 1 + const func3 = () => 1 + const data = [func1, func1, func2, func3] + const uniqueData = uniq(data) + // Depending on the implementation of function toString, func1 and func2 might have the same string representation. + // func3 (an arrow function) likely has a different string representation than func1 and func2. + expect(uniqueData.length).toBeGreaterThanOrEqual(2) + }) + }) +}) diff --git a/packages/data-view/src/__tests__/client.test.ts b/packages/data-view/src/__tests__/client.test.ts new file mode 100644 index 00000000..9d66e1a3 --- /dev/null +++ b/packages/data-view/src/__tests__/client.test.ts @@ -0,0 +1,492 @@ +import { describe, expect, it } from 'vitest' +import { + compareValues, + filterDataByColumns, + filterRowByColumns, + processData, + sortDataByColumns, +} from '../core/client.js' +import type { + Column, + DataViewState, + FiltersState, + SortState, +} from '../core/types.js' + +// ── Test Types ────────────────────────────────────────────── + +type TestRow = { + id: number + title: string + status: string + tags: string[] + priority: number + startDate: Date | null + active: boolean +} + +// ── Helpers ───────────────────────────────────────────────── + +function makeColumn( + id: string, + type: T, + accessor: (row: TestRow) => unknown, +): Column { + return { + id, + type, + accessor, + displayName: id, + } as any +} + +const columns: Column[] = [ + makeColumn('title', 'text', (r) => r.title), + makeColumn('status', 'option', (r) => r.status), + makeColumn('tags', 'multiOption', (r) => r.tags), + makeColumn('priority', 'number', (r) => r.priority), + makeColumn('startDate', 'date', (r) => r.startDate), + makeColumn('active', 'boolean', (r) => r.active), +] + +const testData: TestRow[] = [ + { + id: 1, + title: 'Alpha', + status: 'open', + tags: ['bug', 'urgent'], + priority: 3, + startDate: new Date('2024-03-15'), + active: true, + }, + { + id: 2, + title: 'Beta', + status: 'closed', + tags: ['feature'], + priority: 1, + startDate: new Date('2024-01-01'), + active: false, + }, + { + id: 3, + title: 'Gamma', + status: 'open', + tags: ['bug'], + priority: 5, + startDate: null, + active: true, + }, + { + id: 4, + title: 'Delta', + status: 'pending', + tags: [], + priority: 2, + startDate: new Date('2024-06-20'), + active: false, + }, +] + +// ── compareValues ─────────────────────────────────────────── + +describe('core/client', () => { + describe('compareValues', () => { + it('should compare strings', () => { + expect(compareValues('apple', 'banana')).toBeLessThan(0) + expect(compareValues('banana', 'apple')).toBeGreaterThan(0) + expect(compareValues('same', 'same')).toBe(0) + }) + + it('should compare numbers', () => { + expect(compareValues(1, 2)).toBeLessThan(0) + expect(compareValues(10, 3)).toBeGreaterThan(0) + expect(compareValues(5, 5)).toBe(0) + }) + + it('should compare bigints', () => { + expect(compareValues(1n, 2n)).toBeLessThan(0) + expect(compareValues(100n, 50n)).toBeGreaterThan(0) + expect(compareValues(7n, 7n)).toBe(0) + }) + + it('should compare dates', () => { + const d1 = new Date('2024-01-01') + const d2 = new Date('2024-06-15') + expect(compareValues(d1, d2)).toBeLessThan(0) + expect(compareValues(d2, d1)).toBeGreaterThan(0) + expect(compareValues(d1, d1)).toBe(0) + }) + + it('should compare booleans', () => { + expect(compareValues(false, true)).toBeLessThan(0) + expect(compareValues(true, false)).toBeGreaterThan(0) + expect(compareValues(true, true)).toBe(0) + }) + + it('should compare arrays by length', () => { + expect(compareValues([1], [1, 2, 3])).toBeLessThan(0) + expect(compareValues([1, 2, 3], [1])).toBeGreaterThan(0) + expect(compareValues([1, 2], [3, 4])).toBe(0) + }) + + it('should sort nulls last', () => { + expect(compareValues(null, 'a')).toBeGreaterThan(0) + expect(compareValues('a', null)).toBeLessThan(0) + expect(compareValues(undefined, 5)).toBeGreaterThan(0) + expect(compareValues(5, undefined)).toBeLessThan(0) + expect(compareValues(null, null)).toBe(0) + expect(compareValues(undefined, undefined)).toBe(0) + }) + + it('should fall back to string comparison for mixed types', () => { + const result = compareValues('abc', 'def') + expect(result).toBeLessThan(0) + }) + }) + + // ── filterRowByColumns ────────────────────────────────────── + + describe('filterRowByColumns', () => { + it('should pass row when no filters', () => { + expect(filterRowByColumns(testData[0]!, columns, [])).toBe(true) + }) + + it('should filter by option column (is)', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['open'], + }, + ] + expect(filterRowByColumns(testData[0]!, columns, filters)).toBe(true) + expect(filterRowByColumns(testData[1]!, columns, filters)).toBe(false) + }) + + it('should filter by option column (is any of)', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is_any_of', + values: ['open', 'pending'], + }, + ] + expect(filterRowByColumns(testData[0]!, columns, filters)).toBe(true) // open + expect(filterRowByColumns(testData[1]!, columns, filters)).toBe(false) // closed + expect(filterRowByColumns(testData[3]!, columns, filters)).toBe(true) // pending + }) + + it('should filter by multiOption column (include)', () => { + const filters: FiltersState = [ + { + columnId: 'tags', + type: 'multiOption', + operator: 'include', + values: ['bug'], + }, + ] + expect(filterRowByColumns(testData[0]!, columns, filters)).toBe(true) // has bug + expect(filterRowByColumns(testData[1]!, columns, filters)).toBe(false) // only feature + }) + + it('should filter by text column (contains)', () => { + const filters: FiltersState = [ + { + columnId: 'title', + type: 'text', + operator: 'contains', + values: ['alph'], + }, + ] + expect(filterRowByColumns(testData[0]!, columns, filters)).toBe(true) // Alpha + expect(filterRowByColumns(testData[1]!, columns, filters)).toBe(false) // Beta + }) + + it('should filter by number column (is)', () => { + const filters: FiltersState = [ + { + columnId: 'priority', + type: 'number', + operator: 'is', + values: [3], + }, + ] + expect(filterRowByColumns(testData[0]!, columns, filters)).toBe(true) // 3 + expect(filterRowByColumns(testData[1]!, columns, filters)).toBe(false) // 1 + }) + + it('should filter by boolean column (is)', () => { + const filters: FiltersState = [ + { + columnId: 'active', + type: 'boolean', + operator: 'is', + values: [true], + }, + ] + expect(filterRowByColumns(testData[0]!, columns, filters)).toBe(true) // active + expect(filterRowByColumns(testData[1]!, columns, filters)).toBe(false) // not active + }) + + it('should AND multiple filters', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['open'], + }, + { columnId: 'active', type: 'boolean', operator: 'is', values: [true] }, + ] + expect(filterRowByColumns(testData[0]!, columns, filters)).toBe(true) // open + active + expect(filterRowByColumns(testData[2]!, columns, filters)).toBe(true) // open + active + expect(filterRowByColumns(testData[1]!, columns, filters)).toBe(false) // closed + }) + + it('should skip filters for unknown columns', () => { + const filters: FiltersState = [ + { + columnId: 'nonexistent', + type: 'text', + operator: 'contains', + values: ['x'], + }, + ] + expect(filterRowByColumns(testData[0]!, columns, filters)).toBe(true) + }) + }) + + // ── filterDataByColumns ───────────────────────────────────── + + describe('filterDataByColumns', () => { + it('should return original array when no filters', () => { + const result = filterDataByColumns(testData, columns, []) + expect(result).toBe(testData) // Same reference + }) + + it('should filter data by option column', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['open'], + }, + ] + const result = filterDataByColumns(testData, columns, filters) + expect(result).toHaveLength(2) + expect(result.map((r) => r.id)).toEqual([1, 3]) + }) + + it('should filter by multiple criteria', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['open'], + }, + { + columnId: 'tags', + type: 'multiOption', + operator: 'include', + values: ['urgent'], + }, + ] + const result = filterDataByColumns(testData, columns, filters) + expect(result).toHaveLength(1) + expect(result[0]!.id).toBe(1) + }) + + it('should return empty array when nothing matches', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['nonexistent'], + }, + ] + const result = filterDataByColumns(testData, columns, filters) + expect(result).toHaveLength(0) + }) + }) + + // ── sortDataByColumns ─────────────────────────────────────── + + describe('sortDataByColumns', () => { + it('should return original array when no sort rules', () => { + const result = sortDataByColumns(testData, columns, []) + expect(result).toBe(testData) // Same reference + }) + + it('should sort by string column ascending', () => { + const sort: SortState = [ + { type: 'column', columnId: 'title', direction: 'asc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + expect(result.map((r) => r.title)).toEqual([ + 'Alpha', + 'Beta', + 'Delta', + 'Gamma', + ]) + }) + + it('should sort by string column descending', () => { + const sort: SortState = [ + { type: 'column', columnId: 'title', direction: 'desc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + expect(result.map((r) => r.title)).toEqual([ + 'Gamma', + 'Delta', + 'Beta', + 'Alpha', + ]) + }) + + it('should sort by number column ascending', () => { + const sort: SortState = [ + { type: 'column', columnId: 'priority', direction: 'asc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + expect(result.map((r) => r.priority)).toEqual([1, 2, 3, 5]) + }) + + it('should sort by number column descending', () => { + const sort: SortState = [ + { type: 'column', columnId: 'priority', direction: 'desc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + expect(result.map((r) => r.priority)).toEqual([5, 3, 2, 1]) + }) + + it('should sort by boolean column', () => { + const sort: SortState = [ + { type: 'column', columnId: 'active', direction: 'desc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + // true (1,3) first, then false (2,4) + expect(result.map((r) => r.active)).toEqual([true, true, false, false]) + }) + + it('should sort by date column with nulls last', () => { + const sort: SortState = [ + { type: 'column', columnId: 'startDate', direction: 'asc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + // 2024-01-01, 2024-03-15, 2024-06-20, null + expect(result.map((r) => r.id)).toEqual([2, 1, 4, 3]) + }) + + it('should handle multi-column sort', () => { + const sort: SortState = [ + { type: 'column', columnId: 'active', direction: 'desc' }, + { type: 'column', columnId: 'priority', direction: 'asc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + // First sort by active desc (true first), then by priority asc + expect(result.map((r) => r.id)).toEqual([1, 3, 2, 4]) + }) + + it('should skip custom sort rules', () => { + const sort: SortState = [ + { type: 'custom', id: 'my-sort', enabled: true }, + { type: 'column', columnId: 'priority', direction: 'asc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + expect(result.map((r) => r.priority)).toEqual([1, 2, 3, 5]) + }) + + it('should not mutate original array', () => { + const sort: SortState = [ + { type: 'column', columnId: 'priority', direction: 'desc' }, + ] + const original = [...testData] + sortDataByColumns(testData, columns, sort) + expect(testData).toEqual(original) + }) + + it('should skip sort rules for unknown columns', () => { + const sort: SortState = [ + { type: 'column', columnId: 'nonexistent', direction: 'asc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + // No actual sorting happens, but returns a new array since there's a column sort rule + expect(result.map((r) => r.id)).toEqual([1, 2, 3, 4]) + }) + + it('should sort by array column (by length)', () => { + const sort: SortState = [ + { type: 'column', columnId: 'tags', direction: 'desc' }, + ] + const result = sortDataByColumns(testData, columns, sort) + // ['bug','urgent']=2, ['feature']=1, ['bug']=1, []=0 + expect(result[0]!.id).toBe(1) // 2 tags + expect(result[result.length - 1]!.id).toBe(4) // 0 tags + }) + }) + + // ── processData ─────────────────────────────────────────── + + describe('processData', () => { + it('should filter then sort', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['open'], + }, + ], + sort: [{ type: 'column', columnId: 'priority', direction: 'asc' }], + } + const result = processData(testData, columns, view) + // Only open: Alpha(3), Gamma(5) → sorted asc by priority + expect(result).toHaveLength(2) + expect(result.map((r) => r.id)).toEqual([1, 3]) + }) + + it('should return original data when view is empty', () => { + const view: DataViewState = { filters: [], sort: [] } + const result = processData(testData, columns, view) + expect(result).toBe(testData) // Same reference — no filters and no sort + }) + + it('should handle filters only (no sort)', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'active', + type: 'boolean', + operator: 'is', + values: [false], + }, + ], + sort: [], + } + const result = processData(testData, columns, view) + expect(result).toHaveLength(2) + expect(result.map((r) => r.id)).toEqual([2, 4]) + }) + + it('should handle sort only (no filters)', () => { + const view: DataViewState = { + filters: [], + sort: [{ type: 'column', columnId: 'title', direction: 'desc' }], + } + const result = processData(testData, columns, view) + expect(result.map((r) => r.title)).toEqual([ + 'Gamma', + 'Delta', + 'Beta', + 'Alpha', + ]) + }) + }) +}) diff --git a/packages/data-view/src/__tests__/column-builder.test.ts b/packages/data-view/src/__tests__/column-builder.test.ts new file mode 100644 index 00000000..174c1c6a --- /dev/null +++ b/packages/data-view/src/__tests__/column-builder.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest' +import { + dateType, + defineColumnType, + numberType, + textType, +} from '../core/column-types.js' +import { createColumnBuilder } from '../core/columns/column-builder.js' +import { defineOperators } from '../core/operator-set.js' +import { numberOperators, optionOperators } from '../core/operator-sets.js' + +type TestRow = { + name: string + age: number + status: string + price: number + tags: string[] + active: boolean + createdAt: Date + amount: bigint +} + +const c = createColumnBuilder() + +describe('core/columns/column-builder', () => { + describe('existing methods', () => { + it('should build a text column', () => { + const col = c + .text() + .id('name') + .accessor((r) => r.name) + .displayName('Name') + .build() + + expect(col.id).toBe('name') + expect(col.type).toBe('text') + expect(col.displayName).toBe('Name') + }) + + it('should build an option column with options', () => { + const col = c + .option() + .id('status') + .accessor((r) => r.status) + .displayName('Status') + .options([ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ]) + .build() + + expect(col.type).toBe('option') + expect(col.options).toHaveLength(2) + }) + + it('should build a number column with min/max', () => { + const col = c + .number() + .id('age') + .accessor((r) => r.age) + .displayName('Age') + .min(0) + .max(120) + .build() + + expect(col.min).toBe(0) + expect(col.max).toBe(120) + }) + + it('should throw for missing required fields', () => { + expect(() => c.text().build()).toThrow('id is required') + expect(() => c.text().id('x').build()).toThrow('accessor is required') + expect(() => + c + .text() + .id('x') + .accessor((r) => r.name) + .build(), + ).toThrow('displayName is required') + }) + }) + + describe('.sortable()', () => { + it('should set sortable to true', () => { + const col = c + .text() + .id('name') + .accessor((r) => r.name) + .displayName('Name') + .sortable() + .build() + + expect(col.sortable).toBe(true) + expect(col.defaultSortDirection).toBeUndefined() + }) + + it('should set default sort direction', () => { + const col = c + .number() + .id('age') + .accessor((r) => r.age) + .displayName('Age') + .sortable({ default: { direction: 'desc' } }) + .build() + + expect(col.sortable).toBe(true) + expect(col.defaultSortDirection).toBe('desc') + }) + + it('should be immutable (returns new instance)', () => { + const base = c + .text() + .id('name') + .accessor((r) => r.name) + .displayName('Name') + + const sorted = base.sortable() + const baseCfg = base.build() + const sortedCfg = sorted.build() + + expect(baseCfg.sortable).toBeUndefined() + expect(sortedCfg.sortable).toBe(true) + }) + }) + + describe('.operators()', () => { + it('should set a custom OperatorSet on the column', () => { + const customOps = optionOperators.only('is', 'is_not') + const col = c + .option() + .id('status') + .accessor((r) => r.status) + .displayName('Status') + .operators(customOps) + .build() + + expect(col.operators).toBe(customOps) + expect(col.operators!.size).toBe(2) + }) + + it('should be immutable (returns new instance)', () => { + const customOps = optionOperators.only('is', 'is_not') + const base = c + .option() + .id('status') + .accessor((r) => r.status) + .displayName('Status') + + const withOps = base.operators(customOps) + const baseCfg = base.build() + const withOpsCfg = withOps.build() + + expect(baseCfg.operators).toBeUndefined() + expect(withOpsCfg.operators).toBe(customOps) + }) + + it('should override default operators when used after .custom()', () => { + const currencyType = defineColumnType({ + id: 'currency', + operators: numberOperators, + }) + const limitedOps = numberOperators.only('is', 'is_not') + + const col = c + .custom(currencyType) + .id('price') + .accessor((r) => r.price) + .displayName('Price') + .operators(limitedOps) + .build() + + expect(col.operators).toBe(limitedOps) + expect(col.operators!.size).toBe(2) + }) + }) + + describe('.custom()', () => { + it('should set type, operators, normalizeValues, and columnType', () => { + const currencyType = defineColumnType({ + id: 'currency', + operators: numberOperators, + normalizeValues: (vals) => vals.sort((a, b) => a - b), + }) + + const col = c + .custom(currencyType) + .id('price') + .accessor((r) => r.price) + .displayName('Price') + .build() + + expect(col.type).toBe('currency') + expect(col.operators).toBe(numberOperators) + expect(col.columnType).toBe(currencyType) + expect(col.normalizeValues).toBeDefined() + expect(col.normalizeValues!([3, 1, 2])).toEqual([1, 2, 3]) + }) + + it('should support chaining with .sortable() and .operators()', () => { + const currencyType = defineColumnType({ + id: 'currency', + operators: numberOperators, + }) + const customOps = numberOperators.only('is', 'is_between') + + const col = c + .custom(currencyType) + .id('price') + .accessor((r) => r.price) + .displayName('Price') + .sortable({ default: { direction: 'desc' } }) + .operators(customOps) + .build() + + expect(col.type).toBe('currency') + expect(col.sortable).toBe(true) + expect(col.defaultSortDirection).toBe('desc') + expect(col.operators).toBe(customOps) + }) + }) + + describe('built-in types wire columnType', () => { + it('should set columnType for .text()', () => { + const col = c + .text() + .id('name') + .accessor((r) => r.name) + .displayName('Name') + .build() + + expect(col.columnType).toBe(textType) + }) + + it('should set columnType for .number()', () => { + const col = c + .number() + .id('age') + .accessor((r) => r.age) + .displayName('Age') + .build() + + expect(col.columnType).toBe(numberType) + expect(col.normalizeValues).toBeDefined() + }) + + it('should set columnType for .date()', () => { + const col = c + .date() + .id('createdAt') + .accessor((r) => r.createdAt) + .displayName('Created') + .build() + + expect(col.columnType).toBe(dateType) + expect(col.normalizeValues).toBeDefined() + }) + + it('should set normalizeValues from numberType', () => { + const col = c + .number() + .id('age') + .accessor((r) => r.age) + .displayName('Age') + .build() + + // numberType.normalizeValues sorts range values + expect(col.normalizeValues!([10, 5])).toEqual([5, 10]) + }) + }) + + describe('.field()', () => { + it('should set field on the config', () => { + const col = c + .date() + .id('createdAt') + .accessor((r) => r.createdAt) + .displayName('Created') + .field('created_at') + .build() + + expect(col.field).toBe('created_at') + }) + + it('should be immutable (returns new instance)', () => { + const base = c + .text() + .id('name') + .accessor((r) => r.name) + .displayName('Name') + + const withField = base.field('full_name') + const baseCfg = base.build() + const withFieldCfg = withField.build() + + expect(baseCfg.field).toBeUndefined() + expect(withFieldCfg.field).toBe('full_name') + }) + + it('should work with dot notation for relations', () => { + const col = c + .option() + .id('status') + .accessor((r) => r.status) + .displayName('Status') + .field('status.name') + .build() + + expect(col.field).toBe('status.name') + }) + }) +}) diff --git a/packages/data-view/src/__tests__/column-types.test.ts b/packages/data-view/src/__tests__/column-types.test.ts new file mode 100644 index 00000000..5b34a95f --- /dev/null +++ b/packages/data-view/src/__tests__/column-types.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import { + bigIntType, + booleanType, + builtInColumnTypes, + dateType, + defineColumnType, + multiOptionType, + numberType, + optionType, + textType, +} from '../core/column-types.js' +import { defineOperators } from '../core/operator-set.js' +import { + bigIntOperators, + booleanOperators, + dateOperators, + multiOptionOperators, + numberOperators, + optionOperators, + textOperators, +} from '../core/operator-sets.js' + +describe('core/column-types', () => { + describe('defineColumnType', () => { + it('should create a ColumnType with all provided properties', () => { + const ops = defineOperators({ + eq: { label: 'equals', target: 'single', match: (a, b) => a === b[0] }, + }) + const type = defineColumnType({ + id: 'custom-number', + operators: ops, + normalizeValues: (vals) => vals.sort((a, b) => a - b), + serialize: (v) => v, + deserialize: (raw) => Number(raw), + }) + + expect(type.id).toBe('custom-number') + expect(type.operators).toBe(ops) + expect(type.normalizeValues([3, 1, 2])).toEqual([1, 2, 3]) + expect(type.serialize!(42)).toBe(42) + expect(type.deserialize!('42')).toBe(42) + }) + + it('should default normalizeValues to identity if not provided', () => { + const ops = defineOperators({ + eq: { label: 'equals', target: 'single' }, + }) + const type = defineColumnType({ + id: 'minimal', + operators: ops, + }) + + const values = [1, 2, 3] + expect(type.normalizeValues(values)).toBe(values) // Same reference + }) + + it('should leave serialize/deserialize undefined if not provided', () => { + const ops = defineOperators({ + eq: { label: 'equals', target: 'single' }, + }) + const type = defineColumnType({ + id: 'no-serde', + operators: ops, + }) + + expect(type.serialize).toBeUndefined() + expect(type.deserialize).toBeUndefined() + }) + }) + + describe('built-in column types', () => { + describe('textType', () => { + it('should have id "text"', () => { + expect(textType.id).toBe('text') + }) + + it('should use textOperators', () => { + expect(textType.operators).toBe(textOperators) + }) + + it('should have identity normalizeValues', () => { + const vals = ['a', 'b'] + expect(textType.normalizeValues(vals)).toBe(vals) + }) + + it('should not have serialize/deserialize', () => { + expect(textType.serialize).toBeUndefined() + expect(textType.deserialize).toBeUndefined() + }) + }) + + describe('numberType', () => { + it('should have id "number"', () => { + expect(numberType.id).toBe('number') + }) + + it('should use numberOperators', () => { + expect(numberType.operators).toBe(numberOperators) + }) + + it('should normalize number ranges', () => { + expect(numberType.normalizeValues([10, 5])).toEqual([5, 10]) + expect(numberType.normalizeValues([42])).toEqual([42]) + expect(numberType.normalizeValues([])).toEqual([]) + }) + }) + + describe('bigIntType', () => { + it('should have id "bigint"', () => { + expect(bigIntType.id).toBe('bigint') + }) + + it('should use bigIntOperators', () => { + expect(bigIntType.operators).toBe(bigIntOperators) + }) + + it('should normalize bigint ranges', () => { + expect(bigIntType.normalizeValues([10n, 5n])).toEqual([5n, 10n]) + expect(bigIntType.normalizeValues([42n])).toEqual([42n]) + }) + + it('should serialize to string', () => { + expect(bigIntType.serialize!(42n)).toBe('42') + }) + + it('should deserialize from string', () => { + expect(bigIntType.deserialize!('42')).toBe(42n) + }) + }) + + describe('dateType', () => { + it('should have id "date"', () => { + expect(dateType.id).toBe('date') + }) + + it('should use dateOperators', () => { + expect(dateType.operators).toBe(dateOperators) + }) + + it('should normalize date ranges', () => { + const d1 = new Date(2025, 0, 5) + const d2 = new Date(2025, 0, 1) + const result = dateType.normalizeValues([d1, d2]) + // Should be sorted: earlier first + expect(result[0]).toBe(d2) + expect(result[1]).toBe(d1) + }) + + it('should serialize to ISO string', () => { + const d = new Date(2025, 0, 1, 12, 0, 0) + expect(typeof dateType.serialize!(d)).toBe('string') + }) + + it('should deserialize from ISO string', () => { + const iso = '2025-01-01T12:00:00.000Z' + const result = dateType.deserialize!(iso) + expect(result).toBeInstanceOf(Date) + expect(result.toISOString()).toBe(iso) + }) + }) + + describe('booleanType', () => { + it('should have id "boolean"', () => { + expect(booleanType.id).toBe('boolean') + }) + + it('should use booleanOperators', () => { + expect(booleanType.operators).toBe(booleanOperators) + }) + + it('should have identity normalizeValues', () => { + const vals = [true, false] + expect(booleanType.normalizeValues(vals)).toBe(vals) + }) + }) + + describe('optionType', () => { + it('should have id "option"', () => { + expect(optionType.id).toBe('option') + }) + + it('should use optionOperators', () => { + expect(optionType.operators).toBe(optionOperators) + }) + }) + + describe('multiOptionType', () => { + it('should have id "multiOption"', () => { + expect(multiOptionType.id).toBe('multiOption') + }) + + it('should use multiOptionOperators', () => { + expect(multiOptionType.operators).toBe(multiOptionOperators) + }) + }) + }) + + describe('builtInColumnTypes map', () => { + it('should map all 7 built-in types', () => { + expect(Object.keys(builtInColumnTypes)).toHaveLength(7) + expect(builtInColumnTypes.text).toBe(textType) + expect(builtInColumnTypes.number).toBe(numberType) + expect(builtInColumnTypes.bigint).toBe(bigIntType) + expect(builtInColumnTypes.date).toBe(dateType) + expect(builtInColumnTypes.boolean).toBe(booleanType) + expect(builtInColumnTypes.option).toBe(optionType) + expect(builtInColumnTypes.multiOption).toBe(multiOptionType) + }) + }) +}) diff --git a/packages/data-view/src/__tests__/compile.test.ts b/packages/data-view/src/__tests__/compile.test.ts new file mode 100644 index 00000000..f6f6b881 --- /dev/null +++ b/packages/data-view/src/__tests__/compile.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, it } from 'vitest' +import { createColumnBuilder } from '../core/columns/column-builder.js' +import type { + ColumnSort, + CustomSort, + DataViewState, + FiltersState, + SortState, +} from '../core/types.js' +import type { FieldRef } from '../server/ast.js' +import { + buildQueryAST, + compileFilters, + compileSearch, + compileSort, + serializeFilterValues, +} from '../server/compile.js' +import { resolveFieldRefs } from '../server/resolve.js' + +// ── Helpers ───────────────────────────────────────────────── + +type TestRow = { + title: string + status: string + age: number + active: boolean + createdAt: Date + labels: string[] + amount: bigint +} + +const c = createColumnBuilder() + +const titleCol = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + +const statusCol = c + .option() + .id('status') + .accessor((r) => r.status) + .displayName('Status') + .field('status.name') + .build() + +const ageCol = c + .number() + .id('age') + .accessor((r) => r.age) + .displayName('Age') + .build() + +const activeCol = c + .boolean() + .id('active') + .accessor((r) => r.active) + .displayName('Active') + .build() + +const createdAtCol = c + .date() + .id('createdAt') + .accessor((r) => r.createdAt) + .displayName('Created') + .field('created_at') + .build() + +const labelsCol = c + .multiOption() + .id('labels') + .accessor((r) => r.labels) + .displayName('Labels') + .field('labels.name') + .build() + +const allColumns = [ + titleCol, + statusCol, + ageCol, + activeCol, + createdAtCol, + labelsCol, +] + +const fieldRefs = resolveFieldRefs(allColumns, new Set(['labels'])) + +// ── compileFilters ────────────────────────────────────────── + +describe('server/compile', () => { + describe('compileFilters', () => { + it('should return null for empty filters', () => { + const result = compileFilters([], { fieldRefs }) + expect(result).toBeNull() + }) + + it('should return the condition directly for a single filter', () => { + const filters: FiltersState = [ + { + columnId: 'title', + type: 'text', + operator: 'contains', + values: ['hello'], + }, + ] + + const result = compileFilters(filters, { fieldRefs }) + + expect(result).not.toBeNull() + expect(result!.kind).toBe('comparison') + if (result!.kind === 'comparison') { + expect(result!.op).toBe('ilike') + expect(result!.value).toBe('%hello%') + } + }) + + it('should wrap multiple filters in AND', () => { + const filters: FiltersState = [ + { + columnId: 'title', + type: 'text', + operator: 'contains', + values: ['hello'], + }, + { + columnId: 'age', + type: 'number', + operator: 'is', + values: [42], + }, + ] + + const result = compileFilters(filters, { fieldRefs }) + + expect(result).not.toBeNull() + expect(result!.kind).toBe('and') + if (result!.kind === 'and') { + expect(result!.conditions).toHaveLength(2) + } + }) + + it('should skip unknown columns', () => { + const filters: FiltersState = [ + { + columnId: 'nonexistent', + type: 'text', + operator: 'contains', + values: ['test'], + }, + ] + + const result = compileFilters(filters, { fieldRefs }) + expect(result).toBeNull() + }) + + it('should skip unknown operator types', () => { + const filters: FiltersState = [ + { + columnId: 'title', + type: 'custom_unknown', + operator: 'contains', + values: ['test'], + }, + ] + + const result = compileFilters(filters, { fieldRefs }) + expect(result).toBeNull() + }) + + it('should use custom compilers when provided', () => { + const filters: FiltersState = [ + { + columnId: 'title', + type: 'text', + operator: 'custom_op', + values: ['test'], + }, + ] + + const customCompilers = { + text: { + custom_op: (field: FieldRef, values: any[]) => ({ + kind: 'comparison' as const, + field, + op: 'eq' as const, + value: values[0] ?? null, + }), + }, + } + + const result = compileFilters(filters, { + fieldRefs, + compilers: customCompilers, + }) + + expect(result).not.toBeNull() + expect(result!.kind).toBe('comparison') + if (result!.kind === 'comparison') { + expect(result!.op).toBe('eq') + expect(result!.value).toBe('test') + } + }) + }) + + // ── compileSort ───────────────────────────────────────────── + + describe('compileSort', () => { + it('should compile column sorts into SortNodes', () => { + const sort: SortState = [ + { type: 'column', columnId: 'title', direction: 'asc' } as ColumnSort, + { type: 'column', columnId: 'age', direction: 'desc' } as ColumnSort, + ] + + const result = compileSort(sort, { fieldRefs }) + + expect(result).toHaveLength(2) + expect(result[0]!.field.columnId).toBe('title') + expect(result[0]!.direction).toBe('asc') + expect(result[0]!.nulls).toBe('last') + expect(result[1]!.field.columnId).toBe('age') + expect(result[1]!.direction).toBe('desc') + }) + + it('should skip custom sorts', () => { + const sort: SortState = [ + { type: 'custom', id: 'relevance', enabled: true } as CustomSort, + { type: 'column', columnId: 'title', direction: 'asc' } as ColumnSort, + ] + + const result = compileSort(sort, { fieldRefs }) + + expect(result).toHaveLength(1) + expect(result[0]!.field.columnId).toBe('title') + }) + + it('should skip sorts for unknown columns', () => { + const sort: SortState = [ + { + type: 'column', + columnId: 'nonexistent', + direction: 'asc', + } as ColumnSort, + ] + + const result = compileSort(sort, { fieldRefs }) + expect(result).toHaveLength(0) + }) + + it('should return empty array for empty sort', () => { + const result = compileSort([], { fieldRefs }) + expect(result).toEqual([]) + }) + }) + + // ── compileSearch ─────────────────────────────────────────── + + describe('compileSearch', () => { + it('should return null for empty query', () => { + const result = compileSearch( + { query: '', columns: ['title'] }, + { fieldRefs }, + ) + expect(result).toBeNull() + }) + + it('should return null for whitespace-only query', () => { + const result = compileSearch( + { query: ' ', columns: ['title'] }, + { fieldRefs }, + ) + expect(result).toBeNull() + }) + + it('should return null when no valid columns match', () => { + const result = compileSearch( + { query: 'test', columns: ['nonexistent'] }, + { fieldRefs }, + ) + expect(result).toBeNull() + }) + + it('should produce a SearchNode with valid query and columns', () => { + const result = compileSearch( + { query: 'hello', columns: ['title', 'status'] }, + { fieldRefs }, + ) + + expect(result).not.toBeNull() + expect(result!.query).toBe('hello') + expect(result!.fields).toHaveLength(2) + expect(result!.mode).toBe('contains') + }) + + it('should use the specified mode', () => { + const result = compileSearch( + { query: 'hello', columns: ['title'], mode: 'fulltext' }, + { fieldRefs }, + ) + + expect(result!.mode).toBe('fulltext') + }) + + it('should skip unknown columns in the search list', () => { + const result = compileSearch( + { query: 'hello', columns: ['title', 'nonexistent'] }, + { fieldRefs }, + ) + + expect(result!.fields).toHaveLength(1) + expect(result!.fields[0]!.columnId).toBe('title') + }) + }) + + // ── buildQueryAST ───────────────────────────────────────── + + describe('buildQueryAST', () => { + it('should build a complete AST with filters, sort, pagination, and search', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'title', + type: 'text', + operator: 'contains', + values: ['test'], + }, + { + columnId: 'active', + type: 'boolean', + operator: 'is', + values: [true], + }, + ], + sort: [ + { + type: 'column', + columnId: 'age', + direction: 'desc', + } as ColumnSort, + ], + } + + const ast = buildQueryAST(view, { + columns: allColumns, + hasManyRelations: new Set(['labels']), + pagination: { kind: 'offset', offset: 0, limit: 25 }, + search: { query: 'hello', columns: ['title', 'status'] }, + }) + + // where + expect(ast.where).not.toBeNull() + expect(ast.where!.kind).toBe('and') + + // orderBy + expect(ast.orderBy).toHaveLength(1) + expect(ast.orderBy[0]!.field.columnId).toBe('age') + expect(ast.orderBy[0]!.direction).toBe('desc') + + // pagination + expect(ast.pagination).toEqual({ + kind: 'offset', + offset: 0, + limit: 25, + }) + + // search + expect(ast.search).not.toBeNull() + expect(ast.search!.query).toBe('hello') + }) + + it('should handle empty state', () => { + const view: DataViewState = { + filters: [], + sort: [], + } + + const ast = buildQueryAST(view, { + columns: allColumns, + }) + + expect(ast.where).toBeNull() + expect(ast.orderBy).toEqual([]) + expect(ast.pagination).toBeNull() + expect(ast.search).toBeNull() + }) + + it('should handle pagination without search', () => { + const view: DataViewState = { + filters: [], + sort: [], + } + + const ast = buildQueryAST(view, { + columns: allColumns, + pagination: { + kind: 'cursor', + cursor: 'abc', + limit: 10, + direction: 'forward', + }, + }) + + expect(ast.pagination).toEqual({ + kind: 'cursor', + cursor: 'abc', + limit: 10, + direction: 'forward', + }) + expect(ast.search).toBeNull() + }) + }) + + // ── serializeFilterValues ───────────────────────────────── + + describe('serializeFilterValues', () => { + it('should serialize Date values to ISO strings', () => { + const date = new Date('2025-06-15T12:00:00.000Z') + const result = serializeFilterValues([date], 'date') + + expect(result).toEqual(['2025-06-15T12:00:00.000Z']) + }) + + it('should serialize BigInt values to strings', () => { + const result = serializeFilterValues([BigInt(123456789)], 'bigint') + + expect(result).toEqual(['123456789']) + }) + + it('should pass through plain values unchanged', () => { + const result = serializeFilterValues(['hello', 42, true], 'text') + expect(result).toEqual(['hello', 42, true]) + }) + + it('should pass through number values unchanged', () => { + const result = serializeFilterValues([42, 100], 'number') + expect(result).toEqual([42, 100]) + }) + + it('should handle null values', () => { + const result = serializeFilterValues([null, undefined], 'date') + expect(result).toEqual([null, null]) + }) + + it('should pass through values for unknown types without serializer', () => { + const result = serializeFilterValues(['hello'], 'custom_unknown') + expect(result).toEqual(['hello']) + }) + + it('should use custom column types when provided', () => { + const customTypes = { + currency: { + id: 'currency', + operators: {} as any, + normalizeValues: (v: number[]) => v, + serialize: (v: number) => `$${v}`, + }, + } + + const result = serializeFilterValues([100], 'currency', customTypes) + expect(result).toEqual(['$100']) + }) + }) +}) diff --git a/packages/data-view/src/__tests__/compilers.test.ts b/packages/data-view/src/__tests__/compilers.test.ts new file mode 100644 index 00000000..7d03e623 --- /dev/null +++ b/packages/data-view/src/__tests__/compilers.test.ts @@ -0,0 +1,551 @@ +import { describe, expect, it } from 'vitest' +import type { FieldRef } from '../server/ast.js' +import { + booleanCompilers, + builtInCompilers, + dateCompilers, + escapeLike, + multiOptionCompilers, + numberCompilers, + optionCompilers, + textCompilers, +} from '../server/compilers.js' + +// ── Helpers ───────────────────────────────────────────────── + +function makeDirectField(columnId: string, type: string): FieldRef { + return { + columnId, + type, + path: { kind: 'direct', column: columnId }, + } +} + +function makeHasManyField( + columnId: string, + type: string, + relation: string, + column: string, +): FieldRef { + return { + columnId, + type, + path: { kind: 'hasMany', relation, column }, + } +} + +// ── escapeLike ────────────────────────────────────────────── + +describe('server/compilers', () => { + describe('escapeLike', () => { + it('should escape % characters', () => { + expect(escapeLike('100%')).toBe('100\\%') + }) + + it('should escape _ characters', () => { + expect(escapeLike('hello_world')).toBe('hello\\_world') + }) + + it('should escape \\ characters', () => { + expect(escapeLike('path\\to')).toBe('path\\\\to') + }) + + it('should escape all special characters at once', () => { + expect(escapeLike('%_\\')).toBe('\\%\\_\\\\') + }) + + it('should leave normal text unchanged', () => { + expect(escapeLike('hello world')).toBe('hello world') + }) + }) + + // ── Text Compilers ──────────────────────────────────────── + + describe('textCompilers', () => { + const field = makeDirectField('name', 'text') + + it('contains should produce an ilike comparison', () => { + const result = textCompilers.contains!(field, ['hello']) + + expect(result).toEqual({ + kind: 'comparison', + field, + op: 'ilike', + value: '%hello%', + }) + }) + + it('contains should escape special characters in the value', () => { + const result = textCompilers.contains!(field, ['100%']) + + expect(result).toEqual({ + kind: 'comparison', + field, + op: 'ilike', + value: '%100\\%%', + }) + }) + + it('does_not_contain should produce a NOT(ilike) condition', () => { + const result = textCompilers.does_not_contain!(field, ['hello']) + + expect(result).toEqual({ + kind: 'not', + condition: { + kind: 'comparison', + field, + op: 'ilike', + value: '%hello%', + }, + }) + }) + + it('contains should handle empty values', () => { + const result = textCompilers.contains!(field, []) + + expect(result).toEqual({ + kind: 'comparison', + field, + op: 'ilike', + value: '%%', + }) + }) + }) + + // ── Option Compilers ────────────────────────────────────── + + describe('optionCompilers', () => { + const field = makeDirectField('status', 'option') + + it('is should produce an eq comparison', () => { + expect(optionCompilers.is!(field, ['active'])).toEqual({ + kind: 'comparison', + field, + op: 'eq', + value: 'active', + }) + }) + + it('is_not should produce a neq comparison', () => { + expect(optionCompilers.is_not!(field, ['active'])).toEqual({ + kind: 'comparison', + field, + op: 'neq', + value: 'active', + }) + }) + + it('is_any_of should produce an in comparison', () => { + expect(optionCompilers.is_any_of!(field, ['active', 'pending'])).toEqual({ + kind: 'comparison', + field, + op: 'in', + value: ['active', 'pending'], + }) + }) + + it('is_none_of should produce a notIn comparison', () => { + expect(optionCompilers.is_none_of!(field, ['active', 'pending'])).toEqual( + { + kind: 'comparison', + field, + op: 'notIn', + value: ['active', 'pending'], + }, + ) + }) + + it('is should handle null values', () => { + expect(optionCompilers.is!(field, [])).toEqual({ + kind: 'comparison', + field, + op: 'eq', + value: null, + }) + }) + }) + + // ── Number Compilers ────────────────────────────────────── + + describe('numberCompilers', () => { + const field = makeDirectField('age', 'number') + + it('is should produce eq', () => { + expect(numberCompilers.is!(field, [42])).toEqual({ + kind: 'comparison', + field, + op: 'eq', + value: 42, + }) + }) + + it('is_not should produce neq', () => { + expect(numberCompilers.is_not!(field, [42])).toEqual({ + kind: 'comparison', + field, + op: 'neq', + value: 42, + }) + }) + + it('is_greater_than should produce gt', () => { + expect(numberCompilers.is_greater_than!(field, [10])).toEqual({ + kind: 'comparison', + field, + op: 'gt', + value: 10, + }) + }) + + it('is_gte should produce gte', () => { + expect(numberCompilers.is_gte!(field, [10])).toEqual({ + kind: 'comparison', + field, + op: 'gte', + value: 10, + }) + }) + + it('is_less_than should produce lt', () => { + expect(numberCompilers.is_less_than!(field, [10])).toEqual({ + kind: 'comparison', + field, + op: 'lt', + value: 10, + }) + }) + + it('is_lte should produce lte', () => { + expect(numberCompilers.is_lte!(field, [10])).toEqual({ + kind: 'comparison', + field, + op: 'lte', + value: 10, + }) + }) + + it('is_between should produce between', () => { + expect(numberCompilers.is_between!(field, [5, 10])).toEqual({ + kind: 'comparison', + field, + op: 'between', + value: [5, 10], + }) + }) + + it('is_not_between should produce notBetween', () => { + expect(numberCompilers.is_not_between!(field, [5, 10])).toEqual({ + kind: 'comparison', + field, + op: 'notBetween', + value: [5, 10], + }) + }) + }) + + // ── Date Compilers ──────────────────────────────────────── + + describe('dateCompilers', () => { + const field = makeDirectField('createdAt', 'date') + const dateISO = '2025-06-15T12:00:00.000Z' + + it('is should expand to AND(gte startOfDay, lte endOfDay)', () => { + const result = dateCompilers.is!(field, [dateISO]) + expect(result.kind).toBe('and') + + if (result.kind === 'and') { + expect(result.conditions).toHaveLength(2) + + const [gte, lte] = result.conditions + expect(gte).toMatchObject({ kind: 'comparison', op: 'gte' }) + expect(lte).toMatchObject({ kind: 'comparison', op: 'lte' }) + } + }) + + it('is_not should expand to OR(lt startOfDay, gt endOfDay)', () => { + const result = dateCompilers.is_not!(field, [dateISO]) + expect(result.kind).toBe('or') + + if (result.kind === 'or') { + expect(result.conditions).toHaveLength(2) + + const [lt, gt] = result.conditions + expect(lt).toMatchObject({ kind: 'comparison', op: 'lt' }) + expect(gt).toMatchObject({ kind: 'comparison', op: 'gt' }) + } + }) + + it('is_before should produce lt with startOfDay', () => { + const result = dateCompilers.is_before!(field, [dateISO]) + expect(result).toMatchObject({ kind: 'comparison', op: 'lt' }) + }) + + it('is_on_or_after should produce gte with startOfDay', () => { + const result = dateCompilers.is_on_or_after!(field, [dateISO]) + expect(result).toMatchObject({ kind: 'comparison', op: 'gte' }) + }) + + it('is_after should produce gt with endOfDay', () => { + const result = dateCompilers.is_after!(field, [dateISO]) + expect(result).toMatchObject({ kind: 'comparison', op: 'gt' }) + }) + + it('is_on_or_before should produce lte with endOfDay', () => { + const result = dateCompilers.is_on_or_before!(field, [dateISO]) + expect(result).toMatchObject({ kind: 'comparison', op: 'lte' }) + }) + + it('is_between should produce between with day boundaries', () => { + const result = dateCompilers.is_between!(field, [ + '2025-06-01T12:00:00.000Z', + '2025-06-30T12:00:00.000Z', + ]) + expect(result).toMatchObject({ kind: 'comparison', op: 'between' }) + + if (result.kind === 'comparison') { + expect(Array.isArray(result.value)).toBe(true) + } + }) + + it('is_not_between should produce notBetween with day boundaries', () => { + const result = dateCompilers.is_not_between!(field, [ + '2025-06-01T12:00:00.000Z', + '2025-06-30T12:00:00.000Z', + ]) + expect(result).toMatchObject({ kind: 'comparison', op: 'notBetween' }) + }) + }) + + // ── Boolean Compilers ───────────────────────────────────── + + describe('booleanCompilers', () => { + const field = makeDirectField('active', 'boolean') + + it('is should produce eq', () => { + expect(booleanCompilers.is!(field, [true])).toEqual({ + kind: 'comparison', + field, + op: 'eq', + value: true, + }) + }) + + it('is_not should produce neq', () => { + expect(booleanCompilers.is_not!(field, [false])).toEqual({ + kind: 'comparison', + field, + op: 'neq', + value: false, + }) + }) + }) + + // ── MultiOption Compilers ───────────────────────────────── + + describe('multiOptionCompilers', () => { + const directField = makeDirectField('tags', 'multiOption') + const hasManyField = makeHasManyField( + 'labels', + 'multiOption', + 'labels', + 'name', + ) + + describe('include', () => { + it('with direct field should produce arrayContains', () => { + expect(multiOptionCompilers.include!(directField, ['tag1'])).toEqual({ + kind: 'comparison', + field: directField, + op: 'arrayContains', + value: ['tag1'], + }) + }) + + it('with hasMany field should produce eq', () => { + expect(multiOptionCompilers.include!(hasManyField, ['bug'])).toEqual({ + kind: 'comparison', + field: hasManyField, + op: 'eq', + value: 'bug', + }) + }) + }) + + describe('exclude', () => { + it('with direct field should produce NOT(arrayContains)', () => { + expect(multiOptionCompilers.exclude!(directField, ['tag1'])).toEqual({ + kind: 'not', + condition: { + kind: 'comparison', + field: directField, + op: 'arrayContains', + value: ['tag1'], + }, + }) + }) + + it('with hasMany field should produce NOT(eq)', () => { + expect(multiOptionCompilers.exclude!(hasManyField, ['bug'])).toEqual({ + kind: 'not', + condition: { + kind: 'comparison', + field: hasManyField, + op: 'eq', + value: 'bug', + }, + }) + }) + }) + + describe('include_any_of', () => { + it('with direct field should produce arrayOverlaps', () => { + expect( + multiOptionCompilers.include_any_of!(directField, ['tag1', 'tag2']), + ).toEqual({ + kind: 'comparison', + field: directField, + op: 'arrayOverlaps', + value: ['tag1', 'tag2'], + }) + }) + + it('with hasMany field should produce in', () => { + expect( + multiOptionCompilers.include_any_of!(hasManyField, ['bug', 'feat']), + ).toEqual({ + kind: 'comparison', + field: hasManyField, + op: 'in', + value: ['bug', 'feat'], + }) + }) + }) + + describe('include_all_of', () => { + it('with direct field should produce arrayContains', () => { + expect( + multiOptionCompilers.include_all_of!(directField, ['tag1', 'tag2']), + ).toEqual({ + kind: 'comparison', + field: directField, + op: 'arrayContains', + value: ['tag1', 'tag2'], + }) + }) + + it('with hasMany field should produce AND of eq conditions', () => { + const result = multiOptionCompilers.include_all_of!(hasManyField, [ + 'bug', + 'feat', + ]) + + expect(result).toEqual({ + kind: 'and', + conditions: [ + { kind: 'comparison', field: hasManyField, op: 'eq', value: 'bug' }, + { + kind: 'comparison', + field: hasManyField, + op: 'eq', + value: 'feat', + }, + ], + }) + }) + }) + + describe('exclude_if_any_of', () => { + it('with direct field should produce NOT(arrayOverlaps)', () => { + expect( + multiOptionCompilers.exclude_if_any_of!(directField, [ + 'tag1', + 'tag2', + ]), + ).toEqual({ + kind: 'not', + condition: { + kind: 'comparison', + field: directField, + op: 'arrayOverlaps', + value: ['tag1', 'tag2'], + }, + }) + }) + + it('with hasMany field should produce NOT(in)', () => { + expect( + multiOptionCompilers.exclude_if_any_of!(hasManyField, [ + 'bug', + 'feat', + ]), + ).toEqual({ + kind: 'not', + condition: { + kind: 'comparison', + field: hasManyField, + op: 'in', + value: ['bug', 'feat'], + }, + }) + }) + }) + + describe('exclude_if_all', () => { + it('with direct field should produce NOT(arrayContains)', () => { + expect( + multiOptionCompilers.exclude_if_all!(directField, ['tag1', 'tag2']), + ).toEqual({ + kind: 'not', + condition: { + kind: 'comparison', + field: directField, + op: 'arrayContains', + value: ['tag1', 'tag2'], + }, + }) + }) + + it('with hasMany field should produce NOT(AND of eq)', () => { + const result = multiOptionCompilers.exclude_if_all!(hasManyField, [ + 'bug', + 'feat', + ]) + + expect(result).toEqual({ + kind: 'not', + condition: { + kind: 'and', + conditions: [ + { + kind: 'comparison', + field: hasManyField, + op: 'eq', + value: 'bug', + }, + { + kind: 'comparison', + field: hasManyField, + op: 'eq', + value: 'feat', + }, + ], + }, + }) + }) + }) + }) + + // ── builtInCompilers Registry ───────────────────────────── + + describe('builtInCompilers', () => { + it('should have all 7 built-in types', () => { + expect(builtInCompilers.text).toBe(textCompilers) + expect(builtInCompilers.option).toBe(optionCompilers) + expect(builtInCompilers.number).toBe(numberCompilers) + expect(builtInCompilers.bigint).toBe(numberCompilers) // bigint reuses number compilers + expect(builtInCompilers.date).toBe(dateCompilers) + expect(builtInCompilers.boolean).toBe(booleanCompilers) + expect(builtInCompilers.multiOption).toBe(multiOptionCompilers) + }) + }) +}) diff --git a/packages/data-view/src/__tests__/data-view.test.tsx b/packages/data-view/src/__tests__/data-view.test.tsx new file mode 100644 index 00000000..7e04cc9f --- /dev/null +++ b/packages/data-view/src/__tests__/data-view.test.tsx @@ -0,0 +1,1147 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { + Column, + ColumnConfig, + DataViewState, + FilterStrategy, +} from '../core/types.js' +import { useDataView } from '../react/use-data-view.js' + +// ── Helpers ───────────────────────────────────────────────── + +type TestRow = { + id: number + title: string + status: string + tags: string[] + age: number + active: boolean + createdAt: Date +} + +const testData: TestRow[] = [ + { + id: 1, + title: 'Task A', + status: 'active', + tags: ['bug'], + age: 25, + active: true, + createdAt: new Date('2024-01-01'), + }, + { + id: 2, + title: 'Task B', + status: 'pending', + tags: ['feature', 'urgent'], + age: 30, + active: false, + createdAt: new Date('2024-06-15'), + }, +] + +const columnsConfig = [ + { + id: 'title', + type: 'text', + accessor: (r: TestRow) => r.title, + displayName: 'Title', + }, + { + id: 'status', + type: 'option', + accessor: (r: TestRow) => r.status, + displayName: 'Status', + options: [ + { label: 'Active', value: 'active' }, + { label: 'Pending', value: 'pending' }, + { label: 'Closed', value: 'closed' }, + ], + }, + { + id: 'tags', + type: 'multiOption', + accessor: (r: TestRow) => r.tags, + displayName: 'Tags', + options: [ + { label: 'Bug', value: 'bug' }, + { label: 'Feature', value: 'feature' }, + { label: 'Urgent', value: 'urgent' }, + ], + }, + { + id: 'age', + type: 'number', + accessor: (r: TestRow) => r.age, + displayName: 'Age', + }, + { + id: 'active', + type: 'boolean', + accessor: (r: TestRow) => r.active, + displayName: 'Active', + }, +] as const satisfies ReadonlyArray> + +function renderDataView(overrides?: Record) { + return renderHook(() => + useDataView({ + strategy: 'client' as FilterStrategy, + data: testData, + columnsConfig, + ...overrides, + }), + ) +} + +function getColumn( + result: ReturnType['result'], + id: string, +): Column { + return result.current.columns.find((c) => c.id === id)! +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('useDataView', () => { + // ── Initialization ────────────────────────────────────────── + + describe('initialization', () => { + it('should return empty filters and sort by default', () => { + const { result } = renderDataView() + expect(result.current.filters).toEqual([]) + expect(result.current.sort).toEqual([]) + expect(result.current.view).toEqual({ filters: [], sort: [] }) + }) + + it('should use defaultBaseView for initial base state', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [{ type: 'column', columnId: 'title', direction: 'desc' }], + } + const { result } = renderDataView({ defaultBaseView }) + // Base view state should match + expect(result.current.baseView.filters).toEqual(defaultBaseView.filters) + expect(result.current.baseView.sort).toEqual(defaultBaseView.sort) + // Effective (merged) state should also match since overrides are empty + expect(result.current.filters).toEqual(defaultBaseView.filters) + expect(result.current.sort).toEqual(defaultBaseView.sort) + }) + + it('should create columns from config', () => { + const { result } = renderDataView() + expect(result.current.columns).toHaveLength(5) + expect(result.current.columns.map((c) => c.id)).toEqual([ + 'title', + 'status', + 'tags', + 'age', + 'active', + ]) + }) + + it('should pass strategy through', () => { + const { result } = renderDataView({ strategy: 'server' }) + expect(result.current.strategy).toBe('server') + }) + + it('should pass entityName through', () => { + const { result } = renderDataView({ entityName: 'issues' }) + expect(result.current.entityName).toBe('issues') + }) + + it('should initialize overrides as empty by default', () => { + const { result } = renderDataView() + expect(result.current.overrides.filters).toEqual([]) + expect(result.current.overrides.sort).toEqual([]) + }) + + it('should use defaultOverrides for initial overrides state', () => { + const defaultOverrides: DataViewState = { + filters: [ + { + columnId: 'tags', + type: 'multiOption', + operator: 'include', + values: ['bug'], + }, + ], + sort: [], + } + const { result } = renderDataView({ defaultOverrides }) + expect(result.current.overrides.filters).toEqual(defaultOverrides.filters) + }) + }) + + // ── Controlled Mode ───────────────────────────────────────── + + describe('controlled mode', () => { + it('should throw if baseView is provided without onBaseViewChange', () => { + expect(() => + renderDataView({ + baseView: { filters: [], sort: [] }, + }), + ).toThrow( + '[data-view] If using controlled base view, you must specify both `baseView` and `onBaseViewChange`.', + ) + }) + + it('should throw if onBaseViewChange is provided without baseView', () => { + expect(() => + renderDataView({ + onBaseViewChange: vi.fn(), + }), + ).toThrow( + '[data-view] If using controlled base view, you must specify both `baseView` and `onBaseViewChange`.', + ) + }) + + it('should throw if overrides is provided without onOverridesChange', () => { + expect(() => + renderDataView({ + overrides: { filters: [], sort: [] }, + }), + ).toThrow( + '[data-view] If using controlled overrides, you must specify both `overrides` and `onOverridesChange`.', + ) + }) + + it('should use external base view state in controlled mode', () => { + const externalBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [{ type: 'column', columnId: 'title', direction: 'asc' }], + } + const onBaseViewChange = vi.fn() + const { result } = renderDataView({ + baseView: externalBaseView, + onBaseViewChange, + }) + expect(result.current.baseView.filters).toEqual(externalBaseView.filters) + expect(result.current.baseView.sort).toEqual(externalBaseView.sort) + }) + + it('should call dispatch-style onOverridesChange (arity <= 1)', () => { + const onOverridesChange = vi.fn((_view: DataViewState) => {}) + const externalOverrides: DataViewState = { filters: [], sort: [] } + const { result } = renderDataView({ + overrides: externalOverrides, + onOverridesChange, + }) + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + + expect(onOverridesChange).toHaveBeenCalledTimes(1) + const receivedView = onOverridesChange.mock.calls[0][0] + expect(receivedView.sort).toEqual([ + { type: 'column', columnId: 'title', direction: 'desc' }, + ]) + }) + + it('should call custom handler onOverridesChange (arity > 1) with context', () => { + const onOverridesChange = vi.fn( + ( + _prev: DataViewState, + _next: DataViewState, + _context?: { source: string }, + ) => {}, + ) + const externalOverrides: DataViewState = { filters: [], sort: [] } + const { result } = renderDataView({ + overrides: externalOverrides, + onOverridesChange, + }) + + const statusCol = getColumn(result, 'status') + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active'], { + source: 'toolbar', + }) + }) + + expect(onOverridesChange).toHaveBeenCalledTimes(1) + const [prev, next, context] = onOverridesChange.mock.calls[0] + expect(prev).toEqual(externalOverrides) + expect(next.filters).toHaveLength(1) + expect(next.filters[0].columnId).toBe('status') + expect(context).toEqual({ source: 'toolbar' }) + }) + }) + + // ── Overrides Filter Actions ────────────────────────────── + + describe('overrides filter actions', () => { + it('addFilterValue: should add a filter for an option column', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + + expect(result.current.overrides.filters).toHaveLength(1) + expect(result.current.overrides.filters[0]).toEqual({ + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }) + // Effective filters should also reflect this + expect(result.current.filters).toHaveLength(1) + }) + + it('addFilterValue: should auto-transition operator when adding multiple values', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + act(() => { + result.current.overrides.addFilterValue(statusCol, ['pending']) + }) + + expect(result.current.overrides.filters[0].operator).toBe('is_any_of') + expect(result.current.overrides.filters[0].values).toEqual([ + 'active', + 'pending', + ]) + }) + + it('removeFilterValue: should remove a value from an existing filter', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.overrides.addFilterValue(statusCol, [ + 'active', + 'pending', + ]) + }) + act(() => { + result.current.overrides.removeFilterValue(statusCol, ['pending']) + }) + + expect(result.current.overrides.filters[0].values).toEqual(['active']) + expect(result.current.overrides.filters[0].operator).toBe('is') + }) + + it('removeFilterValue: should remove filter entirely when no values remain', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + act(() => { + result.current.overrides.removeFilterValue(statusCol, ['active']) + }) + + expect(result.current.overrides.filters).toEqual([]) + }) + + it('setFilterValue: should set filter values for a number column', () => { + const { result } = renderDataView() + const ageCol = getColumn(result, 'age') + + act(() => { + result.current.overrides.setFilterValue(ageCol, [10, 50]) + }) + + expect(result.current.overrides.filters).toHaveLength(1) + expect(result.current.overrides.filters[0].columnId).toBe('age') + expect(result.current.overrides.filters[0].values).toEqual([10, 50]) + }) + + it('setFilterOperator: should change the operator', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + act(() => { + result.current.overrides.setFilterOperator('status', 'is_not') + }) + + expect(result.current.overrides.filters[0].operator).toBe('is_not') + }) + + it('removeFilter: should remove a specific column filter', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + const tagsCol = getColumn(result, 'tags') + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + act(() => { + result.current.overrides.addFilterValue(tagsCol, ['bug']) + }) + act(() => { + result.current.overrides.removeFilter('status') + }) + + expect(result.current.overrides.filters).toHaveLength(1) + expect(result.current.overrides.filters[0].columnId).toBe('tags') + }) + + it('removeAllFilters: should clear all override filters', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + const tagsCol = getColumn(result, 'tags') + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + act(() => { + result.current.overrides.addFilterValue(tagsCol, ['bug']) + }) + act(() => { + result.current.overrides.removeAllFilters() + }) + + expect(result.current.overrides.filters).toEqual([]) + }) + }) + + // ── Overrides Sort Actions ──────────────────────────────── + + describe('overrides sort actions', () => { + it('toggleColumnSort: should add desc sort for new column', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + + expect(result.current.overrides.sort).toEqual([ + { type: 'column', columnId: 'title', direction: 'desc' }, + ]) + }) + + it('toggleColumnSort: should cycle desc -> asc -> none', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + expect(result.current.overrides.sort[0]).toEqual({ + type: 'column', + columnId: 'title', + direction: 'desc', + }) + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + expect(result.current.overrides.sort[0]).toEqual({ + type: 'column', + columnId: 'title', + direction: 'asc', + }) + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + expect(result.current.overrides.sort).toEqual([]) + }) + + it('toggleColumnSort: should support multi-column sort', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + act(() => { + result.current.overrides.toggleColumnSort('age') + }) + + expect(result.current.overrides.sort).toHaveLength(2) + }) + + it('setCustomSort: should add a custom sort rule', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.setCustomSort('relevance', true) + }) + + expect(result.current.overrides.sort).toEqual([ + { type: 'custom', id: 'relevance', enabled: true }, + ]) + }) + + it('setCustomSort: should remove a custom sort rule', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.setCustomSort('relevance', true) + }) + act(() => { + result.current.overrides.setCustomSort('relevance', false) + }) + + expect(result.current.overrides.sort).toEqual([]) + }) + + it('setSort (via setSort updater): should replace sort state', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + act(() => { + result.current.overrides.setSort([ + { type: 'column', columnId: 'age', direction: 'asc' }, + ]) + }) + + expect(result.current.overrides.sort).toEqual([ + { type: 'column', columnId: 'age', direction: 'asc' }, + ]) + }) + + it('clearSort: should remove all sort rules', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + act(() => { + result.current.overrides.setCustomSort('relevance', true) + }) + act(() => { + result.current.overrides.clearSort() + }) + + expect(result.current.overrides.sort).toEqual([]) + }) + }) + + // ── Base View Layer ──────────────────────────────────────── + + describe('baseView', () => { + it('load: should replace base view and clear overrides', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + // Add an override first + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + expect(result.current.overrides.filters).toHaveLength(1) + + // Load a new base view + const newBase: DataViewState = { + id: 'bugs', + name: 'Bugs', + filters: [ + { + columnId: 'tags', + type: 'multiOption', + operator: 'include', + values: ['bug'], + }, + ], + sort: [{ type: 'column', columnId: 'title', direction: 'asc' }], + } + + act(() => { + result.current.baseView.load(newBase) + }) + + expect(result.current.baseView.filters).toEqual(newBase.filters) + expect(result.current.baseView.sort).toEqual(newBase.sort) + expect(result.current.baseView.id).toBe('bugs') + expect(result.current.baseView.name).toBe('Bugs') + // Overrides should be cleared + expect(result.current.overrides.filters).toEqual([]) + expect(result.current.overrides.sort).toEqual([]) + }) + + it('should support modifying base filters directly', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.baseView.addFilterValue(statusCol, ['active']) + }) + + expect(result.current.baseView.filters).toHaveLength(1) + expect(result.current.baseView.filters[0].columnId).toBe('status') + }) + + it('setFilters: should accept Updater', () => { + const { result } = renderDataView() + + act(() => { + result.current.baseView.setFilters([ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ]) + }) + + expect(result.current.baseView.filters).toHaveLength(1) + + // Test updater function form + act(() => { + result.current.baseView.setFilters((prev) => [ + ...prev, + { + columnId: 'age', + type: 'number', + operator: 'gt', + values: [10], + }, + ]) + }) + + expect(result.current.baseView.filters).toHaveLength(2) + }) + }) + + // ── Two-Layer Merge ──────────────────────────────────────── + + describe('two-layer merge', () => { + it('effective filters = base + overrides', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [], + } + const { result } = renderDataView({ defaultBaseView }) + const tagsCol = getColumn(result, 'tags') + + // Add an override filter for a different column + act(() => { + result.current.overrides.addFilterValue(tagsCol, ['bug']) + }) + + // Effective filters should contain both base + override + expect(result.current.filters).toHaveLength(2) + expect( + result.current.filters.find((f) => f.columnId === 'status'), + ).toBeTruthy() + expect( + result.current.filters.find((f) => f.columnId === 'tags'), + ).toBeTruthy() + }) + + it('override filter replaces base filter for same column', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [], + } + const { result } = renderDataView({ defaultBaseView }) + const statusCol = getColumn(result, 'status') + + // Override the same column with different values + act(() => { + result.current.overrides.addFilterValue(statusCol, ['pending']) + }) + + // Should only have 1 effective filter (override wins for 'status') + expect(result.current.filters).toHaveLength(1) + expect(result.current.filters[0].values).toEqual(['pending']) + }) + + it('override sort wins when non-empty', () => { + const defaultBaseView: DataViewState = { + filters: [], + sort: [{ type: 'column', columnId: 'title', direction: 'asc' }], + } + const { result } = renderDataView({ defaultBaseView }) + + // Add override sort + act(() => { + result.current.overrides.toggleColumnSort('age') + }) + + // Override sort should win + expect(result.current.sort).toEqual([ + { type: 'column', columnId: 'age', direction: 'desc' }, + ]) + }) + + it('base sort used when overrides sort is empty', () => { + const defaultBaseView: DataViewState = { + filters: [], + sort: [{ type: 'column', columnId: 'title', direction: 'asc' }], + } + const { result } = renderDataView({ defaultBaseView }) + + // No override sort — base should show through + expect(result.current.sort).toEqual([ + { type: 'column', columnId: 'title', direction: 'asc' }, + ]) + }) + + it('overrides.reset() clears overrides but keeps base', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [{ type: 'column', columnId: 'title', direction: 'asc' }], + } + const { result } = renderDataView({ defaultBaseView }) + + // Add overrides + act(() => { + result.current.overrides.toggleColumnSort('age') + }) + const tagsCol = getColumn(result, 'tags') + act(() => { + result.current.overrides.addFilterValue(tagsCol, ['bug']) + }) + + // Now reset overrides + act(() => { + result.current.overrides.reset() + }) + + // Overrides are empty + expect(result.current.overrides.filters).toEqual([]) + expect(result.current.overrides.sort).toEqual([]) + // Base still active + expect(result.current.filters).toEqual(defaultBaseView.filters) + expect(result.current.sort).toEqual(defaultBaseView.sort) + }) + + it('removeAllFilters on overrides does not touch base', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [], + } + const { result } = renderDataView({ defaultBaseView }) + const tagsCol = getColumn(result, 'tags') + + act(() => { + result.current.overrides.addFilterValue(tagsCol, ['bug']) + }) + act(() => { + result.current.overrides.removeAllFilters() + }) + + // Override filters cleared + expect(result.current.overrides.filters).toEqual([]) + // Base filter still there in effective + expect(result.current.filters).toHaveLength(1) + expect(result.current.filters[0].columnId).toBe('status') + }) + }) + + // ── Column State Helpers ────────────────────────────────── + + describe('column state helpers', () => { + it('getIsFiltered: should return true when column is filtered', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + expect(statusCol.getIsFiltered()).toBe(false) + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + + expect(getColumn(result, 'status').getIsFiltered()).toBe(true) + }) + + it('getFilterValue: should return effective filter', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + expect(statusCol.getFilterValue()).toBeUndefined() + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + + const filter = getColumn(result, 'status').getFilterValue() + expect(filter?.columnId).toBe('status') + expect(filter?.values).toEqual(['active']) + }) + + it('getBaseFilterValue / getOverrideFilterValue: should distinguish layers', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [], + } + const { result } = renderDataView({ defaultBaseView }) + const statusCol = getColumn(result, 'status') + const tagsCol = getColumn(result, 'tags') + + // Status is in base, not overrides + expect(statusCol.getBaseFilterValue()).toBeTruthy() + expect(statusCol.getOverrideFilterValue()).toBeUndefined() + + // Tags is not in either + expect(tagsCol.getBaseFilterValue()).toBeUndefined() + expect(tagsCol.getOverrideFilterValue()).toBeUndefined() + + // Add tags to overrides + act(() => { + result.current.overrides.addFilterValue(tagsCol, ['bug']) + }) + + expect(getColumn(result, 'tags').getBaseFilterValue()).toBeUndefined() + expect(getColumn(result, 'tags').getOverrideFilterValue()).toBeTruthy() + }) + + it('setFilterValue: should set override filter via column helper', () => { + const { result } = renderDataView() + const ageCol = getColumn(result, 'age') + + act(() => { + ageCol.setFilterValue([10, 50]) + }) + + expect(result.current.overrides.filters).toHaveLength(1) + expect(result.current.overrides.filters[0].columnId).toBe('age') + }) + + it('addFilterValue / removeFilterValue: via column helper', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + statusCol.addFilterValue(['active']) + }) + expect(result.current.overrides.filters[0].values).toEqual(['active']) + + act(() => { + getColumn(result, 'status').addFilterValue(['pending']) + }) + expect(result.current.overrides.filters[0].values).toEqual([ + 'active', + 'pending', + ]) + + act(() => { + getColumn(result, 'status').removeFilterValue(['active']) + }) + expect(result.current.overrides.filters[0].values).toEqual(['pending']) + }) + + it('removeFilter: should remove override filter via column helper', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + statusCol.addFilterValue(['active']) + }) + act(() => { + getColumn(result, 'status').removeFilter() + }) + + expect(result.current.overrides.filters).toEqual([]) + }) + + it('getIsSorted: should return sort direction', () => { + const { result } = renderDataView() + const titleCol = getColumn(result, 'title') + + expect(titleCol.getIsSorted()).toBe(false) + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + + expect(getColumn(result, 'title').getIsSorted()).toBe('desc') + }) + + it('toggleSorting: should toggle sort via column helper', () => { + const { result } = renderDataView() + const titleCol = getColumn(result, 'title') + + act(() => { + titleCol.toggleSorting() + }) + expect(getColumn(result, 'title').getIsSorted()).toBe('desc') + + act(() => { + getColumn(result, 'title').toggleSorting() + }) + expect(getColumn(result, 'title').getIsSorted()).toBe('asc') + + act(() => { + getColumn(result, 'title').toggleSorting() + }) + expect(getColumn(result, 'title').getIsSorted()).toBe(false) + }) + + it('clearSorting: should clear sort for this column only', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + act(() => { + result.current.overrides.toggleColumnSort('age') + }) + expect(result.current.overrides.sort).toHaveLength(2) + + act(() => { + getColumn(result, 'title').clearSorting() + }) + + expect(result.current.overrides.sort).toHaveLength(1) + expect(result.current.overrides.sort[0]).toEqual({ + type: 'column', + columnId: 'age', + direction: 'desc', + }) + }) + + it('getSortIndex: should return position in sort array', () => { + const { result } = renderDataView() + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + act(() => { + result.current.overrides.toggleColumnSort('age') + }) + + expect(getColumn(result, 'title').getSortIndex()).toBe(0) + expect(getColumn(result, 'age').getSortIndex()).toBe(1) + expect(getColumn(result, 'status').getSortIndex()).toBe(-1) + }) + }) + + // ── Snapshot ────────────────────────────────────────────── + + describe('snapshot', () => { + it('should capture the merged state', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [], + } + const { result } = renderDataView({ defaultBaseView }) + const tagsCol = getColumn(result, 'tags') + + act(() => { + result.current.overrides.addFilterValue(tagsCol, ['bug']) + }) + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + + let snapshot: DataViewState + act(() => { + snapshot = result.current.snapshot({ name: 'Saved View' }) + }) + + expect(snapshot!.name).toBe('Saved View') + // Should contain both base + override filters + expect(snapshot!.filters).toHaveLength(2) + expect(snapshot!.sort).toHaveLength(1) + // Should be independent copy + expect(snapshot!.filters).not.toBe(result.current.filters) + }) + }) + + // ── Batch ─────────────────────────────────────────────────── + + describe('batch', () => { + it('should apply multiple override operations atomically', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + const tagsCol = getColumn(result, 'tags') + + act(() => { + result.current.batch((actions) => { + actions.addFilterValue(statusCol, ['active']) + actions.addFilterValue(tagsCol, ['bug', 'feature']) + }) + }) + + expect(result.current.overrides.filters).toHaveLength(2) + }) + + it('should apply mixed filter + sort operations atomically', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.batch((actions) => { + actions.addFilterValue(statusCol, ['active']) + actions.toggleColumnSort('title') + actions.setCustomSort('relevance', true) + }) + }) + + expect(result.current.overrides.filters).toHaveLength(1) + expect(result.current.overrides.sort).toHaveLength(2) + }) + + it('should clear and re-add in a single batch', () => { + const { result } = renderDataView() + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + + act(() => { + result.current.batch((actions) => { + actions.removeAllFilters() + actions.clearSort() + actions.addFilterValue(statusCol, ['pending']) + actions.toggleColumnSort('age') + }) + }) + + expect(result.current.overrides.filters).toHaveLength(1) + expect(result.current.overrides.filters[0].values).toEqual(['pending']) + expect(result.current.overrides.sort).toEqual([ + { type: 'column', columnId: 'age', direction: 'desc' }, + ]) + }) + }) + + // ── Edge Cases ────────────────────────────────────────────── + + describe('edge cases', () => { + it('should handle empty data array', () => { + const { result } = renderHook(() => + useDataView({ + strategy: 'client', + data: [], + columnsConfig, + }), + ) + expect(result.current.columns).toHaveLength(5) + expect(result.current.filters).toEqual([]) + }) + + it('sort actions on overrides should not affect base filters', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [], + } + const { result } = renderDataView({ defaultBaseView }) + + act(() => { + result.current.overrides.toggleColumnSort('title') + }) + + // Base filters untouched + expect(result.current.baseView.filters).toEqual(defaultBaseView.filters) + }) + + it('filter actions on overrides should not affect base sort', () => { + const defaultBaseView: DataViewState = { + filters: [], + sort: [{ type: 'column', columnId: 'title', direction: 'asc' }], + } + const { result } = renderDataView({ defaultBaseView }) + const statusCol = getColumn(result, 'status') + + act(() => { + result.current.overrides.addFilterValue(statusCol, ['active']) + }) + + // Base sort untouched + expect(result.current.baseView.sort).toEqual(defaultBaseView.sort) + }) + + it('processedData should use merged state for client strategy', () => { + const defaultBaseView: DataViewState = { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [], + } + const { result } = renderDataView({ defaultBaseView }) + + // Base filter: status = active → only Task A (status: 'active') + expect(result.current.processedData).toHaveLength(1) + expect(result.current.processedData[0].title).toBe('Task A') + }) + }) + + // ── createTypedDataView ─────────────────────────────────── + + describe('createTypedDataView', () => { + it('should be importable and callable', async () => { + const { createTypedDataView } = await import('../react/use-data-view.js') + const useTyped = createTypedDataView<{ source: string }>() + expect(typeof useTyped).toBe('function') + }) + }) +}) diff --git a/packages/data-view/src/__tests__/drizzle-pg.test.ts b/packages/data-view/src/__tests__/drizzle-pg.test.ts new file mode 100644 index 00000000..971b0e09 --- /dev/null +++ b/packages/data-view/src/__tests__/drizzle-pg.test.ts @@ -0,0 +1,1455 @@ +import { getTableColumns, sql } from 'drizzle-orm' +import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' +import { + boolean, + integer, + pgTable, + serial, + text, + timestamp, +} from 'drizzle-orm/pg-core' +import { describe, expect, it } from 'vitest' +import { + applyComparisonOp, + applyDataView, + collectRequiredJoins, + comparisonToSQL, + conditionToSQL, + discoverFK, + existsSubquery, + isExplicitBelongsToConfig, + isManyToManyConfig, + type ResolvedBelongsTo, + type ResolvedManyToMany, + type ResolvedRelation, + resolveColumn, + resolveRelation, + searchToSQL, + sortToSQL, +} from '../drizzle/pg.js' +import type { + ComparisonCondition, + Condition, + DataViewQueryAST, + FieldRef, + SearchNode, + SortNode, +} from '../server/ast.js' + +// ── Test Schema ───────────────────────────────────────────── +// Defines a realistic schema: issues → statuses (belongs-to), +// issues → users/assignees (belongs-to), issues ↔ labels (many-to-many). + +const statuses = pgTable('statuses', { + id: serial('id').primaryKey(), + name: text('name').notNull(), +}) + +const users = pgTable('users', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull(), +}) + +const issues = pgTable('issues', { + id: serial('id').primaryKey(), + title: text('title').notNull(), + description: text('description'), + statusId: integer('status_id') + .notNull() + .references(() => statuses.id), + assigneeId: integer('assignee_id').references(() => users.id), + priority: integer('priority').notNull(), + isArchived: boolean('is_archived').notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) + +const labels = pgTable('labels', { + id: serial('id').primaryKey(), + name: text('name').notNull(), +}) + +const issueLabels = pgTable('issue_labels', { + issueId: integer('issue_id') + .notNull() + .references(() => issues.id), + labelId: integer('label_id') + .notNull() + .references(() => labels.id), +}) + +// A table with no FK to issues (for negative tests) +const projects = pgTable('projects', { + id: serial('id').primaryKey(), + name: text('name').notNull(), +}) + +// ── Helpers ───────────────────────────────────────────────── + +function makeDirectField( + columnId: string, + type: string, + column?: string, +): FieldRef { + return { + columnId, + type, + path: { kind: 'direct', column: column ?? columnId }, + } +} + +function makeBelongsToField( + columnId: string, + type: string, + relation: string, + column: string, +): FieldRef { + return { + columnId, + type, + path: { kind: 'belongsTo', relation, column }, + } +} + +function makeHasManyField( + columnId: string, + type: string, + relation: string, + column: string, +): FieldRef { + return { + columnId, + type, + path: { kind: 'hasMany', relation, column }, + } +} + +/** + * Resolves the standard belongs-to relations for the test schema. + * Returns a Map with 'status' and 'assignee' resolved relations. + */ +function resolveStandardRelations(): Map { + const map = new Map() + + const statusRel = resolveRelation('status', statuses, issues) + map.set('status', statusRel) + + const assigneeRel = resolveRelation('assignee', users, issues) + map.set('assignee', assigneeRel) + + const labelsRel = resolveRelation( + 'labels', + { through: issueLabels, to: labels }, + issues, + ) + map.set('labels', labelsRel) + + return map +} + +// ── discoverFK ────────────────────────────────────────────── + +describe('drizzle/pg', () => { + describe('discoverFK', () => { + it('should discover FK from issues to statuses', () => { + const fk = discoverFK(issues, statuses) + + expect(fk).not.toBeNull() + // The source column should be issues.statusId + expect((fk!.sourceColumn as any).name).toBe('status_id') + // The target column should be statuses.id + expect((fk!.targetColumn as any).name).toBe('id') + }) + + it('should discover FK from issues to users', () => { + const fk = discoverFK(issues, users) + + expect(fk).not.toBeNull() + expect((fk!.sourceColumn as any).name).toBe('assignee_id') + }) + + it('should discover FK from issueLabels pivot to issues', () => { + const fk = discoverFK(issueLabels, issues) + + expect(fk).not.toBeNull() + expect((fk!.sourceColumn as any).name).toBe('issue_id') + }) + + it('should discover FK from issueLabels pivot to labels', () => { + const fk = discoverFK(issueLabels, labels) + + expect(fk).not.toBeNull() + expect((fk!.sourceColumn as any).name).toBe('label_id') + }) + + it('should return null when no FK exists', () => { + const fk = discoverFK(issues, projects) + expect(fk).toBeNull() + }) + + it('should return null when tables are unrelated', () => { + const fk = discoverFK(projects, statuses) + expect(fk).toBeNull() + }) + }) + + // ── Type Guards ───────────────────────────────────────────── + + describe('isManyToManyConfig', () => { + it('should return true for { through, to } config', () => { + expect(isManyToManyConfig({ through: issueLabels, to: labels })).toBe( + true, + ) + }) + + it('should return false for a plain table', () => { + expect(isManyToManyConfig(statuses)).toBe(false) + }) + + it('should return false for explicit belongs-to config', () => { + expect( + isManyToManyConfig({ + table: statuses, + on: { + source: issues.statusId as PgColumn, + target: statuses.id as PgColumn, + }, + }), + ).toBe(false) + }) + }) + + describe('isExplicitBelongsToConfig', () => { + it('should return true for { table, on } config', () => { + expect( + isExplicitBelongsToConfig({ + table: statuses, + on: { + source: issues.statusId as PgColumn, + target: statuses.id as PgColumn, + }, + }), + ).toBe(true) + }) + + it('should return false for a plain table', () => { + expect(isExplicitBelongsToConfig(statuses)).toBe(false) + }) + + it('should return false for many-to-many config', () => { + expect( + isExplicitBelongsToConfig({ through: issueLabels, to: labels }), + ).toBe(false) + }) + }) + + // ── resolveRelation ───────────────────────────────────────── + + describe('resolveRelation', () => { + it('should resolve a simple belongs-to relation (table reference)', () => { + const rel = resolveRelation('status', statuses, issues) + + expect(rel.kind).toBe('belongsTo') + if (rel.kind === 'belongsTo') { + expect(rel.relatedTable).toBe(statuses) + expect((rel.sourceFK as any).name).toBe('status_id') + expect((rel.targetPK as any).name).toBe('id') + } + }) + + it('should resolve an explicit belongs-to relation', () => { + const rel = resolveRelation( + 'status', + { + table: statuses, + on: { + source: issues.statusId as PgColumn, + target: statuses.id as PgColumn, + }, + }, + issues, + ) + + expect(rel.kind).toBe('belongsTo') + if (rel.kind === 'belongsTo') { + expect(rel.relatedTable).toBe(statuses) + expect(rel.sourceFK).toBe(issues.statusId) + expect(rel.targetPK).toBe(statuses.id) + } + }) + + it('should resolve a many-to-many relation', () => { + const rel = resolveRelation( + 'labels', + { through: issueLabels, to: labels }, + issues, + ) + + expect(rel.kind).toBe('manyToMany') + if (rel.kind === 'manyToMany') { + expect(rel.pivotTable).toBe(issueLabels) + expect(rel.targetTable).toBe(labels) + expect((rel.pivotSourceFK as any).name).toBe('issue_id') + expect((rel.pivotTargetFK as any).name).toBe('label_id') + } + }) + + it('should throw when simple belongs-to has no FK', () => { + expect(() => resolveRelation('project', projects, issues)).toThrow( + /Cannot find FK from source table/, + ) + }) + + it('should throw when many-to-many pivot has no FK to source', () => { + // projects has no FK from issueLabels + expect(() => + resolveRelation( + 'labels', + { through: issueLabels, to: labels }, + projects, // no FK from issueLabels → projects + ), + ).toThrow(/Cannot find FK from pivot table to source table/) + }) + + it('should throw when many-to-many pivot has no FK to target', () => { + // issueLabels has no FK to projects + expect(() => + resolveRelation( + 'labels', + { through: issueLabels, to: projects }, + issues, + ), + ).toThrow(/Cannot find FK from pivot table to target table/) + }) + }) + + // ── resolveColumn ─────────────────────────────────────────── + + describe('resolveColumn', () => { + const relations = resolveStandardRelations() + + it('should resolve a direct column by JS property name', () => { + const field = makeDirectField('title', 'text', 'title') + const col = resolveColumn(field, issues, relations) + expect((col as any).name).toBe('title') + }) + + it('should resolve a direct column by DB column name', () => { + // 'status_id' is the DB name, 'statusId' is the JS property name + const field = makeDirectField('statusId', 'option', 'status_id') + const col = resolveColumn(field, issues, relations) + expect((col as any).name).toBe('status_id') + }) + + it('should resolve a belongs-to column', () => { + const field = makeBelongsToField('status', 'option', 'status', 'name') + const col = resolveColumn(field, issues, relations) + expect((col as any).name).toBe('name') + }) + + it('should resolve a hasMany column', () => { + const field = makeHasManyField('labels', 'multiOption', 'labels', 'name') + const col = resolveColumn(field, issues, relations) + expect((col as any).name).toBe('name') + }) + + it('should throw for unknown direct column', () => { + const field = makeDirectField('unknown', 'text', 'nonexistent') + expect(() => resolveColumn(field, issues, relations)).toThrow( + /Cannot find column "nonexistent"/, + ) + }) + + it('should throw for unknown belongs-to relation', () => { + const field = makeBelongsToField( + 'status', + 'option', + 'unknown_rel', + 'name', + ) + expect(() => resolveColumn(field, issues, relations)).toThrow( + /Relation "unknown_rel" not found/, + ) + }) + + it('should throw for unknown column on belongs-to related table', () => { + const field = makeBelongsToField( + 'status', + 'option', + 'status', + 'nonexistent', + ) + expect(() => resolveColumn(field, issues, relations)).toThrow( + /Cannot find column "nonexistent"/, + ) + }) + + it('should throw for unknown hasMany relation', () => { + const field = makeHasManyField( + 'labels', + 'multiOption', + 'unknown_rel', + 'name', + ) + expect(() => resolveColumn(field, issues, relations)).toThrow( + /Relation "unknown_rel" not found/, + ) + }) + + it('should throw when using belongs-to path with manyToMany relation', () => { + const field = makeBelongsToField( + 'labels', + 'multiOption', + 'labels', + 'name', + ) + expect(() => resolveColumn(field, issues, relations)).toThrow( + /not a belongs-to relation/, + ) + }) + + it('should throw when using hasMany path with belongsTo relation', () => { + const field = makeHasManyField('status', 'option', 'status', 'name') + expect(() => resolveColumn(field, issues, relations)).toThrow( + /not a many-to-many relation/, + ) + }) + }) + + // ── applyComparisonOp ───────────────────────────────────── + + describe('applyComparisonOp', () => { + const col = getTableColumns(issues).title as PgColumn + + it('should produce SQL for eq', () => { + const result = applyComparisonOp(col, 'eq', 'hello') + expect(result).toBeDefined() + // Drizzle SQL objects are truthy + }) + + it('should produce SQL for neq', () => { + const result = applyComparisonOp(col, 'neq', 'hello') + expect(result).toBeDefined() + }) + + it('should produce SQL for gt', () => { + const result = applyComparisonOp(col, 'gt', 10) + expect(result).toBeDefined() + }) + + it('should produce SQL for gte', () => { + const result = applyComparisonOp(col, 'gte', 10) + expect(result).toBeDefined() + }) + + it('should produce SQL for lt', () => { + const result = applyComparisonOp(col, 'lt', 10) + expect(result).toBeDefined() + }) + + it('should produce SQL for lte', () => { + const result = applyComparisonOp(col, 'lte', 10) + expect(result).toBeDefined() + }) + + it('should produce SQL for ilike', () => { + const result = applyComparisonOp(col, 'ilike', '%test%') + expect(result).toBeDefined() + }) + + it('should produce SQL for like', () => { + const result = applyComparisonOp(col, 'like', '%test%') + expect(result).toBeDefined() + }) + + it('should produce SQL for notIlike', () => { + const result = applyComparisonOp(col, 'notIlike', '%test%') + expect(result).toBeDefined() + }) + + it('should produce SQL for notLike', () => { + const result = applyComparisonOp(col, 'notLike', '%test%') + expect(result).toBeDefined() + }) + + it('should produce SQL for in', () => { + const result = applyComparisonOp(col, 'in', ['a', 'b', 'c']) + expect(result).toBeDefined() + }) + + it('should produce SQL for notIn', () => { + const result = applyComparisonOp(col, 'notIn', ['a', 'b', 'c']) + expect(result).toBeDefined() + }) + + it('should produce SQL for between', () => { + const result = applyComparisonOp(col, 'between', [1, 10]) + expect(result).toBeDefined() + }) + + it('should produce SQL for notBetween', () => { + const result = applyComparisonOp(col, 'notBetween', [1, 10]) + expect(result).toBeDefined() + }) + + it('should produce SQL for isNull', () => { + const result = applyComparisonOp(col, 'isNull', null) + expect(result).toBeDefined() + }) + + it('should produce SQL for isNotNull', () => { + const result = applyComparisonOp(col, 'isNotNull', null) + expect(result).toBeDefined() + }) + + it('should produce SQL for arrayContains', () => { + const result = applyComparisonOp(col, 'arrayContains', ['a', 'b']) + expect(result).toBeDefined() + }) + + it('should produce SQL for arrayOverlaps', () => { + const result = applyComparisonOp(col, 'arrayOverlaps', ['a', 'b']) + expect(result).toBeDefined() + }) + + it('should throw for unsupported operator', () => { + expect(() => applyComparisonOp(col, 'unsupported' as any, 'val')).toThrow( + /Unsupported comparison operator/, + ) + }) + }) + + // ── conditionToSQL ──────────────────────────────────────── + + describe('conditionToSQL', () => { + const relations = resolveStandardRelations() + + it('should handle a simple comparison condition (direct field)', () => { + const cond: ComparisonCondition = { + kind: 'comparison', + field: makeDirectField('title', 'text', 'title'), + op: 'eq', + value: 'hello', + } + + const result = conditionToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + + it('should handle a belongs-to comparison condition', () => { + const cond: ComparisonCondition = { + kind: 'comparison', + field: makeBelongsToField('status', 'option', 'status', 'name'), + op: 'eq', + value: 'open', + } + + const result = conditionToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + + it('should handle AND conditions', () => { + const cond: Condition = { + kind: 'and', + conditions: [ + { + kind: 'comparison', + field: makeDirectField('title', 'text', 'title'), + op: 'eq', + value: 'hello', + }, + { + kind: 'comparison', + field: makeDirectField('priority', 'number', 'priority'), + op: 'gt', + value: 5, + }, + ], + } + + const result = conditionToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + + it('should handle OR conditions', () => { + const cond: Condition = { + kind: 'or', + conditions: [ + { + kind: 'comparison', + field: makeDirectField('title', 'text', 'title'), + op: 'ilike', + value: '%bug%', + }, + { + kind: 'comparison', + field: makeDirectField('title', 'text', 'title'), + op: 'ilike', + value: '%fix%', + }, + ], + } + + const result = conditionToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + + it('should handle NOT conditions', () => { + const cond: Condition = { + kind: 'not', + condition: { + kind: 'comparison', + field: makeDirectField('isArchived', 'boolean', 'isArchived'), + op: 'eq', + value: true, + }, + } + + const result = conditionToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + + it('should handle nested logical conditions', () => { + const cond: Condition = { + kind: 'and', + conditions: [ + { + kind: 'or', + conditions: [ + { + kind: 'comparison', + field: makeDirectField('priority', 'number', 'priority'), + op: 'eq', + value: 1, + }, + { + kind: 'comparison', + field: makeDirectField('priority', 'number', 'priority'), + op: 'eq', + value: 2, + }, + ], + }, + { + kind: 'not', + condition: { + kind: 'comparison', + field: makeDirectField('isArchived', 'boolean', 'isArchived'), + op: 'eq', + value: true, + }, + }, + ], + } + + const result = conditionToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + + it('should route hasMany comparisons through existsSubquery', () => { + const cond: ComparisonCondition = { + kind: 'comparison', + field: makeHasManyField('labels', 'multiOption', 'labels', 'name'), + op: 'eq', + value: 'bug', + } + + const result = conditionToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + }) + + // ── comparisonToSQL ─────────────────────────────────────── + + describe('comparisonToSQL', () => { + const relations = resolveStandardRelations() + + it('should resolve direct field and apply comparison', () => { + const cond: ComparisonCondition = { + kind: 'comparison', + field: makeDirectField('title', 'text', 'title'), + op: 'ilike', + value: '%test%', + } + + const result = comparisonToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + + it('should resolve belongs-to field and apply comparison', () => { + const cond: ComparisonCondition = { + kind: 'comparison', + field: makeBelongsToField('status', 'option', 'status', 'name'), + op: 'in', + value: ['open', 'in_progress'], + } + + const result = comparisonToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + + it('should generate EXISTS subquery for hasMany field', () => { + const cond: ComparisonCondition = { + kind: 'comparison', + field: makeHasManyField('labels', 'multiOption', 'labels', 'name'), + op: 'eq', + value: 'bug', + } + + const result = comparisonToSQL(cond, issues, relations) + expect(result).toBeDefined() + }) + }) + + // ── existsSubquery ──────────────────────────────────────── + + describe('existsSubquery', () => { + const relations = resolveStandardRelations() + + it('should generate an EXISTS subquery for many-to-many eq', () => { + const field = makeHasManyField('labels', 'multiOption', 'labels', 'name') + const result = existsSubquery(field, 'eq', 'bug', issues, relations) + expect(result).toBeDefined() + }) + + it('should generate an EXISTS subquery for many-to-many in', () => { + const field = makeHasManyField('labels', 'multiOption', 'labels', 'name') + const result = existsSubquery( + field, + 'in', + ['bug', 'feature'], + issues, + relations, + ) + expect(result).toBeDefined() + }) + + it('should throw for non-hasMany field', () => { + const field = makeDirectField('title', 'text', 'title') + expect(() => + existsSubquery(field, 'eq', 'hello', issues, relations), + ).toThrow(/existsSubquery called on non-hasMany field/) + }) + + it('should throw when relation is not many-to-many', () => { + const field = makeHasManyField('status', 'option', 'status', 'name') + expect(() => + existsSubquery(field, 'eq', 'open', issues, relations), + ).toThrow(/must be a many-to-many relation/) + }) + }) + + // ── searchToSQL ─────────────────────────────────────────── + + describe('searchToSQL', () => { + const relations = resolveStandardRelations() + + it('should return null for empty query', () => { + const search: SearchNode = { + query: '', + fields: [makeDirectField('title', 'text', 'title')], + mode: 'contains', + } + expect(searchToSQL(search, issues, relations)).toBeNull() + }) + + it('should return null for whitespace-only query', () => { + const search: SearchNode = { + query: ' ', + fields: [makeDirectField('title', 'text', 'title')], + mode: 'contains', + } + expect(searchToSQL(search, issues, relations)).toBeNull() + }) + + it('should produce ILIKE conditions for contains mode with single field', () => { + const search: SearchNode = { + query: 'hello', + fields: [makeDirectField('title', 'text', 'title')], + mode: 'contains', + } + const result = searchToSQL(search, issues, relations) + expect(result).toBeDefined() + expect(result).not.toBeNull() + }) + + it('should produce OR of ILIKE conditions for multiple fields', () => { + const search: SearchNode = { + query: 'hello', + fields: [ + makeDirectField('title', 'text', 'title'), + makeDirectField('description', 'text', 'description'), + ], + mode: 'contains', + } + const result = searchToSQL(search, issues, relations) + expect(result).toBeDefined() + expect(result).not.toBeNull() + }) + + it('should handle belongs-to fields in contains mode', () => { + const search: SearchNode = { + query: 'open', + fields: [makeBelongsToField('status', 'option', 'status', 'name')], + mode: 'contains', + } + const result = searchToSQL(search, issues, relations) + expect(result).toBeDefined() + }) + + it('should handle hasMany fields in contains mode via EXISTS', () => { + const search: SearchNode = { + query: 'bug', + fields: [makeHasManyField('labels', 'multiOption', 'labels', 'name')], + mode: 'contains', + } + const result = searchToSQL(search, issues, relations) + expect(result).toBeDefined() + }) + + it('should produce tsvector/tsquery for fulltext mode', () => { + const search: SearchNode = { + query: 'hello world', + fields: [ + makeDirectField('title', 'text', 'title'), + makeDirectField('description', 'text', 'description'), + ], + mode: 'fulltext', + } + const result = searchToSQL(search, issues, relations) + expect(result).toBeDefined() + }) + + it('should skip hasMany fields in fulltext mode', () => { + const search: SearchNode = { + query: 'hello', + fields: [makeHasManyField('labels', 'multiOption', 'labels', 'name')], + mode: 'fulltext', + } + // All fields are hasMany, so nothing left to search + const result = searchToSQL(search, issues, relations) + expect(result).toBeNull() + }) + + it('should return null when no fields yield conditions', () => { + const search: SearchNode = { + query: 'hello', + fields: [], + mode: 'contains', + } + const result = searchToSQL(search, issues, relations) + expect(result).toBeNull() + }) + + it('should mix direct and hasMany fields in contains mode', () => { + const search: SearchNode = { + query: 'search term', + fields: [ + makeDirectField('title', 'text', 'title'), + makeHasManyField('labels', 'multiOption', 'labels', 'name'), + ], + mode: 'contains', + } + const result = searchToSQL(search, issues, relations) + expect(result).toBeDefined() + }) + }) + + // ── sortToSQL ───────────────────────────────────────────── + + describe('sortToSQL', () => { + const relations = resolveStandardRelations() + + it('should produce asc SQL for ascending sort', () => { + const sorts: SortNode[] = [ + { + field: makeDirectField('title', 'text', 'title'), + direction: 'asc', + }, + ] + const result = sortToSQL(sorts, issues, relations) + expect(result).toHaveLength(1) + expect(result[0]).toBeDefined() + }) + + it('should produce desc SQL for descending sort', () => { + const sorts: SortNode[] = [ + { + field: makeDirectField('priority', 'number', 'priority'), + direction: 'desc', + }, + ] + const result = sortToSQL(sorts, issues, relations) + expect(result).toHaveLength(1) + }) + + it('should handle multiple sort rules', () => { + const sorts: SortNode[] = [ + { + field: makeDirectField('priority', 'number', 'priority'), + direction: 'desc', + }, + { + field: makeDirectField('title', 'text', 'title'), + direction: 'asc', + }, + ] + const result = sortToSQL(sorts, issues, relations) + expect(result).toHaveLength(2) + }) + + it('should return empty array for empty sorts', () => { + const result = sortToSQL([], issues, relations) + expect(result).toEqual([]) + }) + + it('should handle belongs-to field in sort', () => { + const sorts: SortNode[] = [ + { + field: makeBelongsToField('status', 'option', 'status', 'name'), + direction: 'asc', + }, + ] + const result = sortToSQL(sorts, issues, relations) + expect(result).toHaveLength(1) + }) + }) + + // ── collectRequiredJoins ────────────────────────────────── + + describe('collectRequiredJoins', () => { + it('should collect belongs-to relations from WHERE conditions', () => { + const ast: DataViewQueryAST = { + where: { + kind: 'comparison', + field: makeBelongsToField('status', 'option', 'status', 'name'), + op: 'eq', + value: 'open', + }, + orderBy: [], + pagination: null, + search: null, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + expect(joins.has('status')).toBe(true) + expect(joins.size).toBe(1) + }) + + it('should collect belongs-to relations from ORDER BY', () => { + const ast: DataViewQueryAST = { + where: null, + orderBy: [ + { + field: makeBelongsToField('status', 'option', 'status', 'name'), + direction: 'asc', + }, + ], + pagination: null, + search: null, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + expect(joins.has('status')).toBe(true) + }) + + it('should collect belongs-to relations from search fields', () => { + const ast: DataViewQueryAST = { + where: null, + orderBy: [], + pagination: null, + search: { + query: 'test', + fields: [makeBelongsToField('status', 'option', 'status', 'name')], + mode: 'contains', + }, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + expect(joins.has('status')).toBe(true) + }) + + it('should NOT collect hasMany relations (they use EXISTS)', () => { + const ast: DataViewQueryAST = { + where: { + kind: 'comparison', + field: makeHasManyField('labels', 'multiOption', 'labels', 'name'), + op: 'eq', + value: 'bug', + }, + orderBy: [], + pagination: null, + search: null, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + expect(joins.size).toBe(0) + }) + + it('should NOT collect direct field references', () => { + const ast: DataViewQueryAST = { + where: { + kind: 'comparison', + field: makeDirectField('title', 'text', 'title'), + op: 'eq', + value: 'hello', + }, + orderBy: [], + pagination: null, + search: null, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + expect(joins.size).toBe(0) + }) + + it('should deduplicate joins from multiple sources', () => { + const ast: DataViewQueryAST = { + where: { + kind: 'and', + conditions: [ + { + kind: 'comparison', + field: makeBelongsToField('status', 'option', 'status', 'name'), + op: 'eq', + value: 'open', + }, + { + kind: 'comparison', + field: makeBelongsToField('status', 'option', 'status', 'name'), + op: 'neq', + value: 'closed', + }, + ], + }, + orderBy: [ + { + field: makeBelongsToField('status', 'option', 'status', 'name'), + direction: 'asc', + }, + ], + pagination: null, + search: null, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + // Should be deduplicated to just 'status' + expect(joins.size).toBe(1) + expect(joins.has('status')).toBe(true) + }) + + it('should collect multiple distinct relations', () => { + const ast: DataViewQueryAST = { + where: { + kind: 'and', + conditions: [ + { + kind: 'comparison', + field: makeBelongsToField('status', 'option', 'status', 'name'), + op: 'eq', + value: 'open', + }, + { + kind: 'comparison', + field: makeBelongsToField( + 'assignee', + 'option', + 'assignee', + 'name', + ), + op: 'eq', + value: 'Alice', + }, + ], + }, + orderBy: [], + pagination: null, + search: null, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + expect(joins.size).toBe(2) + expect(joins.has('status')).toBe(true) + expect(joins.has('assignee')).toBe(true) + }) + + it('should handle nested NOT conditions', () => { + const ast: DataViewQueryAST = { + where: { + kind: 'not', + condition: { + kind: 'comparison', + field: makeBelongsToField('status', 'option', 'status', 'name'), + op: 'eq', + value: 'closed', + }, + }, + orderBy: [], + pagination: null, + search: null, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + expect(joins.has('status')).toBe(true) + }) + + it('should handle empty AST', () => { + const ast: DataViewQueryAST = { + where: null, + orderBy: [], + pagination: null, + search: null, + } + + const joins = new Set() + collectRequiredJoins(ast, joins) + + expect(joins.size).toBe(0) + }) + }) + + // ── applyDataView (integration) ─────────────────────────── + + describe('applyDataView', () => { + /** + * Creates a mock Drizzle PgDatabase that captures the query structure + * instead of executing against a real DB. + */ + function createMockDb(mockData: any[] = [], mockCount = 0) { + // Track what was called for assertions + const calls: { + select: any + from: any + joins: any[] + where: any + orderBy: any[] + limit: any + offset: any + } = { + select: null, + from: null, + joins: [], + where: null, + orderBy: [], + limit: null, + offset: null, + } + + // Create a chainable query builder mock + function createQueryChain(isCount = false) { + const chain: any = { + from(table: any) { + if (isCount) { + calls.from = table + } + return chain + }, + innerJoin(table: any, on: any) { + calls.joins.push({ table, on }) + return chain + }, + where(condition: any) { + calls.where = condition + return chain + }, + orderBy(...args: any[]) { + calls.orderBy = args + return chain + }, + limit(n: any) { + calls.limit = n + return chain + }, + offset(n: any) { + calls.offset = n + return chain + }, + // biome-ignore lint/suspicious/noThenProperty: intentional thenable to mock Drizzle query chain + then(resolve: any) { + if (isCount) { + resolve([{ count: mockCount }]) + } else { + resolve(mockData) + } + return chain + }, + } + return chain + } + + const db: any = { + select(selectArgs?: any) { + const isCount = + selectArgs && Object.keys(selectArgs).includes('count') + const chain = createQueryChain(isCount) + calls.select = selectArgs + return chain + }, + } + + return { db, calls } + } + + it('should execute and return data + totalCount', async () => { + const mockRows = [ + { id: 1, title: 'Bug report' }, + { id: 2, title: 'Feature request' }, + ] + const { db } = createMockDb(mockRows, 42) + + const { createColumnBuilder } = await import( + '../core/columns/column-builder.js' + ) + const c = createColumnBuilder<(typeof mockRows)[0]>() + + const titleCol = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + + const result = await applyDataView(db, { + table: issues, + columns: [titleCol], + view: { filters: [], sort: [] }, + }) + + expect(result.data).toEqual(mockRows) + expect(result.totalCount).toBe(42) + }) + + it('should handle offset pagination', async () => { + const { db, calls } = createMockDb([], 100) + const { createColumnBuilder } = await import( + '../core/columns/column-builder.js' + ) + const c = createColumnBuilder<{ id: number; title: string }>() + const titleCol = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + + await applyDataView(db, { + table: issues, + columns: [titleCol], + view: { filters: [], sort: [] }, + pagination: { kind: 'offset', page: 3, pageSize: 25 }, + }) + + // Page 3 with pageSize 25 → offset 50, limit 25 + expect(calls.limit).toBe(25) + expect(calls.offset).toBe(50) + }) + + it('should handle cursor pagination', async () => { + const { db, calls } = createMockDb([], 100) + const { createColumnBuilder } = await import( + '../core/columns/column-builder.js' + ) + const c = createColumnBuilder<{ id: number; title: string }>() + const titleCol = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + + await applyDataView(db, { + table: issues, + columns: [titleCol], + view: { filters: [], sort: [] }, + pagination: { kind: 'cursor', cursor: 'abc', limit: 10 }, + }) + + // Cursor pagination: limit + 1 to detect hasNextPage + expect(calls.limit).toBe(11) + }) + + it('should apply the transform hook', async () => { + const { db } = createMockDb([], 0) + const { createColumnBuilder } = await import( + '../core/columns/column-builder.js' + ) + const c = createColumnBuilder<{ id: number; title: string }>() + const titleCol = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + + let transformCalled = false + let receivedAST: DataViewQueryAST | null = null + + await applyDataView(db, { + table: issues, + columns: [titleCol], + view: { filters: [], sort: [] }, + transform: (ast) => { + transformCalled = true + receivedAST = ast + // Add a soft-delete filter + return { + ...ast, + where: ast.where + ? { + kind: 'and', + conditions: [ + ast.where, + { + kind: 'comparison', + field: makeDirectField( + 'isArchived', + 'boolean', + 'isArchived', + ), + op: 'eq', + value: false, + }, + ], + } + : { + kind: 'comparison', + field: makeDirectField('isArchived', 'boolean', 'isArchived'), + op: 'eq', + value: false, + }, + } + }, + }) + + expect(transformCalled).toBe(true) + expect(receivedAST).not.toBeNull() + }) + + it('should resolve belongs-to relations and add JOINs', async () => { + const { db, calls } = createMockDb([], 0) + const { createColumnBuilder } = await import( + '../core/columns/column-builder.js' + ) + const c = createColumnBuilder<{ + id: number + title: string + status: string + }>() + + const statusCol = c + .option() + .id('status') + .accessor((r) => r.status) + .displayName('Status') + .field('status.name') + .build() + + await applyDataView(db, { + table: issues, + columns: [statusCol], + view: { + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['open'], + }, + ], + sort: [], + }, + relations: { + status: statuses, + }, + }) + + // Should have added a JOIN for the status relation + expect(calls.joins.length).toBeGreaterThanOrEqual(1) + }) + + it('should handle many-to-many relations without adding JOINs', async () => { + const { db, calls } = createMockDb([], 0) + const { createColumnBuilder } = await import( + '../core/columns/column-builder.js' + ) + const c = createColumnBuilder<{ + id: number + title: string + labels: string[] + }>() + + const labelsCol = c + .multiOption() + .id('labels') + .accessor((r) => r.labels) + .displayName('Labels') + .field('labels.name') + .build() + + await applyDataView(db, { + table: issues, + columns: [labelsCol], + view: { + filters: [ + { + columnId: 'labels', + type: 'multiOption', + operator: 'include', + values: ['bug'], + }, + ], + sort: [], + }, + relations: { + labels: { through: issueLabels, to: labels }, + }, + }) + + // Many-to-many uses EXISTS, not JOINs — but the count query also + // doesn't need a JOIN for hasMany. The join count should be 0 for + // many-to-many relations (they use EXISTS subqueries). + // Filter all joins for the labels table specifically + const labelsJoins = calls.joins.filter((j: any) => j.table === labels) + expect(labelsJoins).toHaveLength(0) + }) + + it('should handle empty view state', async () => { + const { db } = createMockDb([{ id: 1, title: 'Test' }], 1) + const { createColumnBuilder } = await import( + '../core/columns/column-builder.js' + ) + const c = createColumnBuilder<{ id: number; title: string }>() + const titleCol = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + + const result = await applyDataView(db, { + table: issues, + columns: [titleCol], + view: { filters: [], sort: [] }, + }) + + expect(result.data).toHaveLength(1) + expect(result.totalCount).toBe(1) + }) + }) +}) diff --git a/packages/data-view/src/__tests__/filter-operations.test.ts b/packages/data-view/src/__tests__/filter-operations.test.ts new file mode 100644 index 00000000..1ab238a7 --- /dev/null +++ b/packages/data-view/src/__tests__/filter-operations.test.ts @@ -0,0 +1,572 @@ +import { describe, expect, it } from 'vitest' +import { filterOperations } from '../core/filters.js' +import { defaultOperatorSets, optionOperators } from '../core/operator-sets.js' +import { determineNewOperator, getOperatorSet } from '../core/operators.js' +import type { Column, FilterModel, FiltersState } from '../core/types.js' +import { filterData, filterRow } from '../lib/helpers.js' + +// ── Helpers ───────────────────────────────────────────────── + +function makeColumn(id: string, type: T): Column { + return { + id, + type, + accessor: (row: any) => row[id], + displayName: id, + } as any +} + +const optionCol = makeColumn('status', 'option') +const multiOptionCol = makeColumn('tags', 'multiOption') +const textCol = makeColumn('name', 'text') +const numberCol = makeColumn('age', 'number') +const dateCol = makeColumn('createdAt', 'date') +const booleanCol = makeColumn('active', 'boolean') + +// ── filterOperations ──────────────────────────────────────── + +describe('core/filters', () => { + describe('filterOperations.addFilterValue', () => { + it('should create a new filter for an unfiltered option column', () => { + const filters: FiltersState = [] + const result = filterOperations.addFilterValue(filters, optionCol, [ + 'active', + ]) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }) + }) + + it('should use multiple default for multiple initial values', () => { + const filters: FiltersState = [] + const result = filterOperations.addFilterValue(filters, optionCol, [ + 'active', + 'pending', + ]) + expect(result[0]!.operator).toBe('is_any_of') + expect(result[0]!.values).toEqual(['active', 'pending']) + }) + + it('should append values to existing filter', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ] + const result = filterOperations.addFilterValue(filters, optionCol, [ + 'pending', + ]) + expect(result).toHaveLength(1) + expect(result[0]!.values).toEqual(['active', 'pending']) + }) + + it('should auto-transition operator from single to multiple', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ] + const result = filterOperations.addFilterValue(filters, optionCol, [ + 'pending', + ]) + // 'is' has singular: 'is_any_of', so transitioning 1→2 should switch to 'is_any_of' + expect(result[0]!.operator).toBe('is_any_of') + }) + + it('should work with multiOption columns', () => { + const filters: FiltersState = [] + const result = filterOperations.addFilterValue(filters, multiOptionCol, [ + 'tag1', + ]) + expect(result[0]!.operator).toBe('include') + }) + + it('should throw for non-option column types', () => { + expect(() => + filterOperations.addFilterValue([] as FiltersState, textCol as any, [ + 'x', + ]), + ).toThrow('addFilterValue() is only supported for option and multiOption') + }) + }) + + describe('filterOperations.removeFilterValue', () => { + it('should remove a value from existing filter', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is_any_of', + values: ['active', 'pending'], + }, + ] + const result = filterOperations.removeFilterValue(filters, optionCol, [ + 'pending', + ]) + expect(result).toHaveLength(1) + expect(result[0]!.values).toEqual(['active']) + }) + + it('should auto-transition operator from multiple to single', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is_any_of', + values: ['active', 'pending'], + }, + ] + const result = filterOperations.removeFilterValue(filters, optionCol, [ + 'pending', + ]) + // 'is_any_of' has plural: 'is', so transitioning 2→1 should switch to 'is' + expect(result[0]!.operator).toBe('is') + }) + + it('should remove the filter entirely when no values remain', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ] + const result = filterOperations.removeFilterValue(filters, optionCol, [ + 'active', + ]) + expect(result).toHaveLength(0) + }) + + it('should return unchanged filters if column is not filtered', () => { + const filters: FiltersState = [] + const result = filterOperations.removeFilterValue(filters, optionCol, [ + 'active', + ]) + expect(result).toEqual([]) + }) + + it('should throw for non-option column types', () => { + expect(() => + filterOperations.removeFilterValue([] as FiltersState, textCol as any, [ + 'x', + ]), + ).toThrow( + 'removeFilterValue() is only supported for option and multiOption', + ) + }) + }) + + describe('filterOperations.setFilterValue', () => { + it('should create a new text filter', () => { + const filters: FiltersState = [] + const result = filterOperations.setFilterValue(filters, textCol, [ + 'hello', + ]) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + columnId: 'name', + type: 'text', + operator: 'contains', + values: ['hello'], + }) + }) + + it('should create a new number filter', () => { + const filters: FiltersState = [] + const result = filterOperations.setFilterValue(filters, numberCol, [42]) + expect(result).toHaveLength(1) + expect(result[0]!.operator).toBe('is') + expect(result[0]!.values).toEqual([42]) + }) + + it('should normalize number range values', () => { + const filters: FiltersState = [] + const result = filterOperations.setFilterValue( + filters, + numberCol, + [10, 5], + ) + // Should normalize to [5, 10] + expect(result[0]!.values).toEqual([5, 10]) + expect(result[0]!.operator).toBe('is_between') + }) + + it('should update existing filter', () => { + const filters: FiltersState = [ + { + columnId: 'name', + type: 'text', + operator: 'contains', + values: ['old'], + }, + ] + const result = filterOperations.setFilterValue(filters, textCol, ['new']) + expect(result).toHaveLength(1) + expect(result[0]!.values).toEqual(['new']) + }) + + it('should not create filter with empty values', () => { + const filters: FiltersState = [] + const result = filterOperations.setFilterValue(filters, numberCol, []) + expect(result).toHaveLength(0) + }) + + it('should create a boolean filter', () => { + const filters: FiltersState = [] + const result = filterOperations.setFilterValue(filters, booleanCol, [ + true, + ]) + expect(result[0]!.operator).toBe('is') + expect(result[0]!.values).toEqual([true]) + }) + + it('should create a date filter', () => { + const filters: FiltersState = [] + const d = new Date(2025, 0, 1) + const result = filterOperations.setFilterValue(filters, dateCol, [d]) + expect(result[0]!.operator).toBe('is') + expect(result[0]!.values).toEqual([d]) + }) + }) + + describe('filterOperations.setFilterOperator', () => { + it('should update the operator of an existing filter', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ] + const result = filterOperations.setFilterOperator( + filters, + 'status', + 'is_not', + ) + expect(result[0]!.operator).toBe('is_not') + expect(result[0]!.values).toEqual(['active']) + }) + + it('should not affect other filters', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + { columnId: 'name', type: 'text', operator: 'contains', values: ['x'] }, + ] + const result = filterOperations.setFilterOperator( + filters, + 'status', + 'is_not', + ) + expect(result[1]!.operator).toBe('contains') + }) + }) + + describe('filterOperations.removeFilter', () => { + it('should remove the filter for a column', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + { columnId: 'name', type: 'text', operator: 'contains', values: ['x'] }, + ] + const result = filterOperations.removeFilter(filters, 'status') + expect(result).toHaveLength(1) + expect(result[0]!.columnId).toBe('name') + }) + }) + + describe('filterOperations.removeAllFilters', () => { + it('should return an empty array', () => { + const result = filterOperations.removeAllFilters() + expect(result).toEqual([]) + }) + }) +}) + +// ── determineNewOperator ──────────────────────────────────── + +describe('core/operators', () => { + describe('determineNewOperator', () => { + it('should return current operator when counts are the same', () => { + const result = determineNewOperator(optionOperators, ['a'], ['b'], 'is') + expect(result).toBe('is') + }) + + it('should return current operator when both are multiple', () => { + const result = determineNewOperator( + optionOperators, + ['a', 'b'], + ['a', 'b', 'c'], + 'is_any_of', + ) + expect(result).toBe('is_any_of') + }) + + it('should transition from single to multiple (singular pointer)', () => { + const result = determineNewOperator( + optionOperators, + ['a'], + ['a', 'b'], + 'is', + ) + // 'is' has singular: 'is_any_of' + expect(result).toBe('is_any_of') + }) + + it('should transition from multiple to single (plural pointer)', () => { + const result = determineNewOperator( + optionOperators, + ['a', 'b'], + ['a'], + 'is_any_of', + ) + // 'is_any_of' has plural: 'is' + expect(result).toBe('is') + }) + + it('should keep current operator if no transition reference', () => { + const result = determineNewOperator( + defaultOperatorSets.text, + ['a'], + ['a', 'b'], + 'contains', + ) + // 'contains' has no singular reference + expect(result).toBe('contains') + }) + + it('should keep current operator if not found in set', () => { + const result = determineNewOperator( + optionOperators, + ['a'], + ['a', 'b'], + 'custom-op', + ) + expect(result).toBe('custom-op') + }) + + it('should transition 0→2 (empty to multiple)', () => { + const result = determineNewOperator(optionOperators, [], ['a', 'b'], 'is') + expect(result).toBe('is_any_of') + }) + + it('should transition 2→0 (multiple to empty)', () => { + const result = determineNewOperator( + optionOperators, + ['a', 'b'], + [], + 'is_any_of', + ) + expect(result).toBe('is') + }) + }) + + describe('getOperatorSet', () => { + it('should return default set for built-in column type', () => { + const col = makeColumn('status', 'option') + const set = getOperatorSet(col) + expect(set).toBe(defaultOperatorSets.option) + }) + + it('should return custom operators when provided on column', () => { + const customSet = optionOperators.only('is', 'is_not') + const col = { ...makeColumn('status', 'option'), operators: customSet } + const set = getOperatorSet(col) + expect(set).toBe(customSet) + }) + + it('should throw for unknown column type with no operators', () => { + const col = makeColumn('custom', 'currency' as any) + expect(() => getOperatorSet(col)).toThrow( + 'No operator set found for column type "currency"', + ) + }) + }) +}) + +// ── filterRow / filterData ────────────────────────────────── + +describe('lib/helpers — filterRow & filterData', () => { + const data = [ + { name: 'Alice', status: 'active', age: 30, active: true }, + { name: 'Bob', status: 'inactive', age: 25, active: false }, + { name: 'Charlie', status: 'active', age: 35, active: true }, + { name: 'Diana', status: 'pending', age: 28, active: false }, + ] + + describe('filterRow', () => { + it('should return true when no filters', () => { + expect(filterRow(data[0], [])).toBe(true) + }) + + it('should filter by text operator', () => { + const filters: FiltersState = [ + { + columnId: 'name', + type: 'text', + operator: 'contains', + values: ['ali'], + }, + ] + expect(filterRow(data[0], filters)).toBe(true) // Alice + expect(filterRow(data[1], filters)).toBe(false) // Bob + }) + + it('should filter by option operator', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ] + expect(filterRow(data[0], filters)).toBe(true) // active + expect(filterRow(data[1], filters)).toBe(false) // inactive + }) + + it('should filter by number operator', () => { + const filters: FiltersState = [ + { + columnId: 'age', + type: 'number', + operator: 'is_greater_than', + values: [28], + }, + ] + expect(filterRow(data[0], filters)).toBe(true) // 30 + expect(filterRow(data[1], filters)).toBe(false) // 25 + }) + + it('should apply multiple filters (AND)', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + { + columnId: 'age', + type: 'number', + operator: 'is_greater_than', + values: [31], + }, + ] + expect(filterRow(data[0], filters)).toBe(false) // active but age=30 + expect(filterRow(data[2], filters)).toBe(true) // active and age=35 + }) + + it('should throw for unknown filter type without custom resolver', () => { + const filters: FiltersState = [ + { columnId: 'x', type: 'currency' as any, operator: 'is', values: [1] }, + ] + expect(() => filterRow(data[0], filters)).toThrow( + 'No operator set found for filter type "currency"', + ) + }) + + it('should use custom operator set resolver', () => { + const customFilters: FiltersState = [ + { + columnId: 'name', + type: 'custom', + operator: 'starts with', + values: ['A'], + }, + ] + const customSet = { + has: (id: string) => id === 'starts with', + get: (id: string) => ({ + id: 'starts with', + label: 'starts with', + target: 'single' as const, + match: (cell: any, vals: any[]) => + typeof cell === 'string' && cell.startsWith(vals[0]), + }), + } + + const resolver = (filter: FilterModel) => { + if (filter.type === 'custom') return customSet as any + return undefined + } + + expect(filterRow(data[0], customFilters, resolver)).toBe(true) // Alice + expect(filterRow(data[1], customFilters, resolver)).toBe(false) // Bob + }) + }) + + describe('filterData', () => { + it('should return all data with no filters', () => { + expect(filterData(data, [])).toEqual(data) + }) + + it('should filter data array', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ] + const result = filterData(data, filters) + expect(result).toHaveLength(2) + expect(result.map((r) => r.name)).toEqual(['Alice', 'Charlie']) + }) + + it('should handle boolean filters', () => { + const filters: FiltersState = [ + { + columnId: 'active', + type: 'boolean', + operator: 'is', + values: [false], + }, + ] + const result = filterData(data, filters) + expect(result).toHaveLength(2) + expect(result.map((r) => r.name)).toEqual(['Bob', 'Diana']) + }) + + it('should handle multiple filters', () => { + const filters: FiltersState = [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + { + columnId: 'name', + type: 'text', + operator: 'contains', + values: ['char'], + }, + ] + const result = filterData(data, filters) + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('Charlie') + }) + }) +}) diff --git a/packages/data-view/src/__tests__/operator-set.test.ts b/packages/data-view/src/__tests__/operator-set.test.ts new file mode 100644 index 00000000..6ae09e56 --- /dev/null +++ b/packages/data-view/src/__tests__/operator-set.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from 'vitest' +import { defineOperators, OperatorSet } from '../core/operator-set.js' + +type TestOps = 'is' | 'is not' | 'is any of' | 'is none of' + +// Helper: create a simple operator set for testing +function createTestSet() { + return defineOperators( + { + is: { + label: 'is', + target: 'single', + i18nKey: 'test.is', + singular: 'is any of', + match: (cell, vals) => vals.includes(cell), + }, + 'is not': { + label: 'is not', + target: 'single', + i18nKey: 'test.isNot', + singular: 'is none of', + match: (cell, vals) => !vals.includes(cell), + }, + 'is any of': { + label: 'is any of', + target: 'multiple', + i18nKey: 'test.isAnyOf', + plural: 'is', + match: (cell, vals) => vals.includes(cell), + }, + 'is none of': { + label: 'is none of', + target: 'multiple', + i18nKey: 'test.isNoneOf', + plural: 'is not', + match: (cell, vals) => !vals.includes(cell), + }, + }, + { defaultSingle: 'is', defaultMultiple: 'is any of' }, + ) +} + +describe('core/operator-set', () => { + describe('defineOperators', () => { + it('should create an OperatorSet from a record', () => { + const set = createTestSet() + expect(set).toBeInstanceOf(OperatorSet) + expect(set.size).toBe(4) + }) + + it('should inject id from record keys', () => { + const set = createTestSet() + const op = set.get('is') + expect(op.id).toBe('is') + expect(op.label).toBe('is') + }) + + it('should preserve insertion order', () => { + const set = createTestSet() + expect(set.ids()).toEqual(['is', 'is not', 'is any of', 'is none of']) + }) + }) + + describe('query methods', () => { + const set = createTestSet() + + describe('.get()', () => { + it('should return the operator by id', () => { + const op = set.get('is') + expect(op.id).toBe('is') + expect(op.label).toBe('is') + expect(op.target).toBe('single') + }) + + it('should throw for unknown id', () => { + expect(() => set.get('unknown' as any)).toThrow( + '[OperatorSet] Operator "unknown" not found', + ) + }) + }) + + describe('.all()', () => { + it('should return all operators in order', () => { + const all = set.all() + expect(all).toHaveLength(4) + expect(all.map((o) => o.id)).toEqual([ + 'is', + 'is not', + 'is any of', + 'is none of', + ]) + }) + }) + + describe('.ids()', () => { + it('should return all ids in order', () => { + expect(set.ids()).toEqual(['is', 'is not', 'is any of', 'is none of']) + }) + }) + + describe('.has()', () => { + it('should return true for existing operators', () => { + expect(set.has('is')).toBe(true) + expect(set.has('is any of')).toBe(true) + }) + + it('should return false for non-existing operators', () => { + expect(set.has('unknown')).toBe(false) + }) + }) + + describe('.size', () => { + it('should return the number of operators', () => { + expect(set.size).toBe(4) + }) + }) + + describe('.getDefault()', () => { + it('should return explicit default for single', () => { + const op = set.getDefault('single') + expect(op.id).toBe('is') + }) + + it('should return explicit default for multiple', () => { + const op = set.getDefault('multiple') + expect(op.id).toBe('is any of') + }) + + it('should fall back to first matching target if no explicit default', () => { + const noDefault = defineOperators({ + contains: { label: 'contains', target: 'single' }, + between: { label: 'between', target: 'multiple' }, + }) + expect(noDefault.getDefault('single').id).toBe('contains') + expect(noDefault.getDefault('multiple').id).toBe('between') + }) + + it('should fall back to first operator if no matching target', () => { + const singleOnly = defineOperators({ + contains: { label: 'contains', target: 'single' }, + }) + // Asking for multiple default, but only single exists — should return first + expect(singleOnly.getDefault('multiple').id).toBe('contains') + }) + + it('should throw on empty OperatorSet', () => { + const empty = new OperatorSet(new Map()) + expect(() => empty.getDefault('single')).toThrow( + 'Cannot get default from empty OperatorSet', + ) + }) + }) + }) + + describe('composition methods', () => { + describe('.only()', () => { + it('should restrict to specified operators', () => { + const set = createTestSet() + const restricted = set.only('is', 'is not') + expect(restricted.size).toBe(2) + expect(restricted.ids()).toEqual(['is', 'is not']) + }) + + it('should preserve order from the original set', () => { + const set = createTestSet() + const restricted = set.only('is none of', 'is') + // Order should follow the order in .only() args, not original insertion + expect(restricted.ids()).toEqual(['is none of', 'is']) + }) + + it('should return a new instance', () => { + const set = createTestSet() + const restricted = set.only('is') + expect(restricted).not.toBe(set) + // Original unchanged + expect(set.size).toBe(4) + }) + + it('should throw for unknown id', () => { + const set = createTestSet() + expect(() => set.only('unknown' as any)).toThrow( + '[OperatorSet.only] Operator "unknown" not found', + ) + }) + }) + + describe('.without()', () => { + it('should remove specified operators', () => { + const set = createTestSet() + const reduced = set.without('is not', 'is none of') + expect(reduced.size).toBe(2) + expect(reduced.ids()).toEqual(['is', 'is any of']) + }) + + it('should return a new instance', () => { + const set = createTestSet() + const reduced = set.without('is') + expect(reduced).not.toBe(set) + expect(set.size).toBe(4) + }) + + it('should silently ignore ids not in the set', () => { + const set = createTestSet() + const reduced = set.without('unknown' as any) + expect(reduced.size).toBe(4) + }) + }) + + describe('.extend()', () => { + it('should add new operators', () => { + const set = createTestSet() + const extended = set.extend({ + startsWith: { + label: 'starts with', + target: 'single', + }, + }) + expect(extended.size).toBe(5) + expect(extended.has('startsWith')).toBe(true) + expect(extended.get('startsWith').label).toBe('starts with') + }) + + it('should preserve existing operators', () => { + const set = createTestSet() + const extended = set.extend({ + custom: { label: 'custom', target: 'single' }, + }) + expect(extended.get('is').label).toBe('is') + }) + + it('should return a new instance', () => { + const set = createTestSet() + const extended = set.extend({ + custom: { label: 'custom', target: 'single' }, + }) + expect(extended).not.toBe(set) + expect(set.size).toBe(4) + }) + }) + + describe('.replace()', () => { + it('should override properties of an existing operator', () => { + const set = createTestSet() + const replaced = set.replace('is', { label: 'equals' }) + expect(replaced.get('is').label).toBe('equals') + // Other properties preserved + expect(replaced.get('is').target).toBe('single') + expect(replaced.get('is').id).toBe('is') + }) + + it('should return a new instance', () => { + const set = createTestSet() + const replaced = set.replace('is', { label: 'equals' }) + expect(replaced).not.toBe(set) + // Original unchanged + expect(set.get('is').label).toBe('is') + }) + + it('should throw for unknown id', () => { + const set = createTestSet() + expect(() => set.replace('unknown' as any, { label: 'x' })).toThrow( + '[OperatorSet.replace] Operator "unknown" not found', + ) + }) + }) + + describe('.defaults()', () => { + it('should update default single', () => { + const set = createTestSet() + const updated = set.defaults({ single: 'is not' }) + expect(updated.getDefault('single').id).toBe('is not') + // Multiple unchanged + expect(updated.getDefault('multiple').id).toBe('is any of') + }) + + it('should update default multiple', () => { + const set = createTestSet() + const updated = set.defaults({ multiple: 'is none of' }) + expect(updated.getDefault('multiple').id).toBe('is none of') + }) + + it('should update both defaults', () => { + const set = createTestSet() + const updated = set.defaults({ + single: 'is not', + multiple: 'is none of', + }) + expect(updated.getDefault('single').id).toBe('is not') + expect(updated.getDefault('multiple').id).toBe('is none of') + }) + + it('should return a new instance', () => { + const set = createTestSet() + const updated = set.defaults({ single: 'is not' }) + expect(updated).not.toBe(set) + expect(set.getDefault('single').id).toBe('is') + }) + }) + + describe('chaining', () => { + it('should support .without().extend().defaults() chain', () => { + const set = createTestSet() + const result = set + .without('is none of') + .extend({ + 'matches regex': { + label: 'matches regex', + target: 'single', + }, + }) + .defaults({ single: 'matches regex' }) + + expect(result.size).toBe(4) // removed 1, added 1 + expect(result.has('is none of')).toBe(false) + expect(result.has('matches regex')).toBe(true) + expect(result.getDefault('single').id).toBe('matches regex') + }) + }) + }) +}) diff --git a/packages/data-view/src/__tests__/operator-sets.test.ts b/packages/data-view/src/__tests__/operator-sets.test.ts new file mode 100644 index 00000000..dbf1834d --- /dev/null +++ b/packages/data-view/src/__tests__/operator-sets.test.ts @@ -0,0 +1,739 @@ +import { describe, expect, it } from 'vitest' +import { + bigIntOperators, + booleanOperators, + dateOperators, + defaultOperatorSets, + multiOptionOperators, + numberOperators, + optionOperators, + textOperators, +} from '../core/operator-sets.js' + +// ── Helpers ───────────────────────────────────────────────── + +function match(set: any, opId: string, cell: any, vals: any[]) { + const op = set.get(opId) + expect(op.match).toBeDefined() + return op.match!(cell, vals) +} + +// ── Text Operators ────────────────────────────────────────── + +describe('core/operator-sets', () => { + describe('textOperators', () => { + it('should have 2 operators', () => { + expect(textOperators.size).toBe(2) + expect(textOperators.ids()).toEqual(['contains', 'does_not_contain']) + }) + + it('should have i18nKey on all operators', () => { + for (const op of textOperators.all()) { + expect(op.i18nKey).toBeDefined() + } + }) + + describe('contains', () => { + it('should match when value contains query', () => { + expect(match(textOperators, 'contains', 'Hello World', ['world'])).toBe( + true, + ) + }) + + it('should be case-insensitive', () => { + expect(match(textOperators, 'contains', 'Hello', ['HELLO'])).toBe(true) + }) + + it('should not match when value does not contain query', () => { + expect(match(textOperators, 'contains', 'Hello', ['xyz'])).toBe(false) + }) + + it('should pass with empty filter values', () => { + expect(match(textOperators, 'contains', 'Hello', [])).toBe(true) + }) + + it('should pass with empty string query', () => { + expect(match(textOperators, 'contains', 'Hello', [''])).toBe(true) + }) + }) + + describe('does_not_contain', () => { + it('should match when value does not contain query', () => { + expect(match(textOperators, 'does_not_contain', 'Hello', ['xyz'])).toBe( + true, + ) + }) + + it('should not match when value contains query', () => { + expect( + match(textOperators, 'does_not_contain', 'Hello World', ['world']), + ).toBe(false) + }) + + it('should pass with empty filter values', () => { + expect(match(textOperators, 'does_not_contain', 'Hello', [])).toBe(true) + }) + }) + }) + + // ── Option Operators ──────────────────────────────────────── + + describe('optionOperators', () => { + it('should have 4 operators', () => { + expect(optionOperators.size).toBe(4) + }) + + it('should have correct singular/plural relationships', () => { + expect(optionOperators.get('is').singular).toBe('is_any_of') + expect(optionOperators.get('is_not').singular).toBe('is_none_of') + expect(optionOperators.get('is_any_of').plural).toBe('is') + expect(optionOperators.get('is_none_of').plural).toBe('is_not') + }) + + it('should have correct defaults', () => { + expect(optionOperators.getDefault('single').id).toBe('is') + expect(optionOperators.getDefault('multiple').id).toBe('is_any_of') + }) + + describe('is', () => { + it('should match when cell equals filter value', () => { + expect(match(optionOperators, 'is', 'active', ['active'])).toBe(true) + }) + + it('should be case-insensitive', () => { + expect(match(optionOperators, 'is', 'Active', ['active'])).toBe(true) + }) + + it('should not match when cell differs', () => { + expect(match(optionOperators, 'is', 'active', ['inactive'])).toBe(false) + }) + + it('should pass with empty filter values', () => { + expect(match(optionOperators, 'is', 'active', [])).toBe(true) + }) + + it('should not match null cell', () => { + expect(match(optionOperators, 'is', null, ['active'])).toBe(false) + }) + }) + + describe('is_not', () => { + it('should match when cell differs', () => { + expect(match(optionOperators, 'is_not', 'active', ['inactive'])).toBe( + true, + ) + }) + + it('should not match when cell equals', () => { + expect(match(optionOperators, 'is_not', 'active', ['active'])).toBe( + false, + ) + }) + }) + + describe('is_any_of', () => { + it('should match when cell is in filter values', () => { + expect(match(optionOperators, 'is_any_of', 'a', ['a', 'b', 'c'])).toBe( + true, + ) + }) + + it('should not match when cell is not in filter values', () => { + expect(match(optionOperators, 'is_any_of', 'z', ['a', 'b'])).toBe(false) + }) + }) + + describe('is_none_of', () => { + it('should match when cell is not in filter values', () => { + expect(match(optionOperators, 'is_none_of', 'z', ['a', 'b'])).toBe(true) + }) + + it('should not match when cell is in filter values', () => { + expect(match(optionOperators, 'is_none_of', 'a', ['a', 'b'])).toBe( + false, + ) + }) + }) + }) + + // ── Multi-Option Operators ────────────────────────────────── + + describe('multiOptionOperators', () => { + it('should have 6 operators', () => { + expect(multiOptionOperators.size).toBe(6) + }) + + it('should have correct defaults', () => { + expect(multiOptionOperators.getDefault('single').id).toBe('include') + expect(multiOptionOperators.getDefault('multiple').id).toBe( + 'include_any_of', + ) + }) + + describe('include', () => { + it('should match when cell array intersects filter', () => { + expect(match(multiOptionOperators, 'include', ['a', 'b'], ['a'])).toBe( + true, + ) + }) + + it('should not match when no intersection', () => { + expect(match(multiOptionOperators, 'include', ['a', 'b'], ['c'])).toBe( + false, + ) + }) + + it('should pass with empty filter', () => { + expect(match(multiOptionOperators, 'include', ['a'], [])).toBe(true) + expect(match(multiOptionOperators, 'include', ['a'], [''])).toBe(true) + }) + + it('should not match non-array cell', () => { + expect(match(multiOptionOperators, 'include', 'not-array', ['a'])).toBe( + false, + ) + }) + }) + + describe('exclude', () => { + it('should match when no intersection', () => { + expect(match(multiOptionOperators, 'exclude', ['a', 'b'], ['c'])).toBe( + true, + ) + }) + + it('should not match when intersection exists', () => { + expect(match(multiOptionOperators, 'exclude', ['a', 'b'], ['a'])).toBe( + false, + ) + }) + }) + + describe('include_any_of', () => { + it('should match when any filter value is in cell', () => { + expect( + match(multiOptionOperators, 'include_any_of', ['a', 'b'], ['b', 'c']), + ).toBe(true) + }) + + it('should not match when none overlap', () => { + expect( + match(multiOptionOperators, 'include_any_of', ['a'], ['b', 'c']), + ).toBe(false) + }) + }) + + describe('include_all_of', () => { + it('should match when all filter values are in cell', () => { + expect( + match( + multiOptionOperators, + 'include_all_of', + ['a', 'b', 'c'], + ['a', 'b'], + ), + ).toBe(true) + }) + + it('should not match when not all filter values are in cell', () => { + expect( + match(multiOptionOperators, 'include_all_of', ['a'], ['a', 'b']), + ).toBe(false) + }) + }) + + describe('exclude_if_any_of', () => { + it('should match when no overlap', () => { + expect( + match(multiOptionOperators, 'exclude_if_any_of', ['a'], ['b', 'c']), + ).toBe(true) + }) + + it('should not match when any overlap', () => { + expect( + match(multiOptionOperators, 'exclude_if_any_of', ['a', 'b'], ['b']), + ).toBe(false) + }) + }) + + describe('exclude_if_all', () => { + it('should match when not all filter values in cell', () => { + expect( + match(multiOptionOperators, 'exclude_if_all', ['a'], ['a', 'b']), + ).toBe(true) + }) + + it('should not match when all filter values in cell', () => { + expect( + match( + multiOptionOperators, + 'exclude_if_all', + ['a', 'b', 'c'], + ['a', 'b'], + ), + ).toBe(false) + }) + }) + }) + + // ── Number Operators ──────────────────────────────────────── + + describe('numberOperators', () => { + it('should have 8 operators', () => { + expect(numberOperators.size).toBe(8) + }) + + it('should have correct defaults', () => { + expect(numberOperators.getDefault('single').id).toBe('is') + expect(numberOperators.getDefault('multiple').id).toBe('is_between') + }) + + describe('is', () => { + it('should match equal numbers', () => { + expect(match(numberOperators, 'is', 42, [42])).toBe(true) + }) + + it('should not match unequal numbers', () => { + expect(match(numberOperators, 'is', 42, [43])).toBe(false) + }) + + it('should pass with undefined filter value', () => { + expect(match(numberOperators, 'is', 42, [undefined])).toBe(true) + }) + + it('should not match undefined cell', () => { + expect(match(numberOperators, 'is', undefined, [42])).toBe(false) + }) + }) + + describe('is_not', () => { + it('should match unequal numbers', () => { + expect(match(numberOperators, 'is_not', 42, [43])).toBe(true) + }) + + it('should not match equal numbers', () => { + expect(match(numberOperators, 'is_not', 42, [42])).toBe(false) + }) + }) + + describe('is_greater_than', () => { + it('should match when cell > filter', () => { + expect(match(numberOperators, 'is_greater_than', 10, [5])).toBe(true) + }) + + it('should not match when cell <= filter', () => { + expect(match(numberOperators, 'is_greater_than', 5, [5])).toBe(false) + expect(match(numberOperators, 'is_greater_than', 3, [5])).toBe(false) + }) + + it('should handle Infinity', () => { + expect( + match( + numberOperators, + 'is_greater_than', + Number.POSITIVE_INFINITY, + [100], + ), + ).toBe(true) + expect( + match(numberOperators, 'is_greater_than', 100, [ + Number.POSITIVE_INFINITY, + ]), + ).toBe(false) + }) + }) + + describe('is_gte', () => { + it('should match when cell >= filter', () => { + expect(match(numberOperators, 'is_gte', 5, [5])).toBe(true) + expect(match(numberOperators, 'is_gte', 10, [5])).toBe(true) + }) + + it('should not match when cell < filter', () => { + expect(match(numberOperators, 'is_gte', 3, [5])).toBe(false) + }) + }) + + describe('is_less_than', () => { + it('should match when cell < filter', () => { + expect(match(numberOperators, 'is_less_than', 3, [5])).toBe(true) + }) + + it('should not match when cell >= filter', () => { + expect(match(numberOperators, 'is_less_than', 5, [5])).toBe(false) + }) + + it('should handle -Infinity', () => { + expect( + match( + numberOperators, + 'is_less_than', + Number.NEGATIVE_INFINITY, + [100], + ), + ).toBe(true) + expect( + match(numberOperators, 'is_less_than', 100, [ + Number.NEGATIVE_INFINITY, + ]), + ).toBe(false) + }) + }) + + describe('is_lte', () => { + it('should match when cell <= filter', () => { + expect(match(numberOperators, 'is_lte', 5, [5])).toBe(true) + expect(match(numberOperators, 'is_lte', 3, [5])).toBe(true) + }) + + it('should not match when cell > filter', () => { + expect(match(numberOperators, 'is_lte', 10, [5])).toBe(false) + }) + }) + + describe('is_between', () => { + it('should match when cell is in range (inclusive)', () => { + expect(match(numberOperators, 'is_between', 5, [1, 10])).toBe(true) + expect(match(numberOperators, 'is_between', 1, [1, 10])).toBe(true) + expect(match(numberOperators, 'is_between', 10, [1, 10])).toBe(true) + }) + + it('should not match when cell is outside range', () => { + expect(match(numberOperators, 'is_between', 0, [1, 10])).toBe(false) + expect(match(numberOperators, 'is_between', 11, [1, 10])).toBe(false) + }) + + it('should handle reversed range', () => { + expect(match(numberOperators, 'is_between', 5, [10, 1])).toBe(true) + }) + + it('should pass with incomplete filter values', () => { + expect(match(numberOperators, 'is_between', 5, [1])).toBe(true) + expect(match(numberOperators, 'is_between', 5, [1, undefined])).toBe( + true, + ) + }) + }) + + describe('is_not_between', () => { + it('should match when cell is outside range', () => { + expect(match(numberOperators, 'is_not_between', 0, [1, 10])).toBe(true) + expect(match(numberOperators, 'is_not_between', 11, [1, 10])).toBe(true) + }) + + it('should not match when cell is in range', () => { + expect(match(numberOperators, 'is_not_between', 5, [1, 10])).toBe(false) + }) + }) + + it('should have correct singular/plural relationships', () => { + expect(numberOperators.get('is').singular).toBe('is_between') + expect(numberOperators.get('is_not').singular).toBe('is_not_between') + expect(numberOperators.get('is_between').plural).toBe('is') + expect(numberOperators.get('is_not_between').plural).toBe('is_not') + }) + }) + + // ── BigInt Operators ──────────────────────────────────────── + + describe('bigIntOperators', () => { + it('should have 8 operators', () => { + expect(bigIntOperators.size).toBe(8) + }) + + describe('is', () => { + it('should match equal bigints', () => { + expect(match(bigIntOperators, 'is', 42n, [42n])).toBe(true) + }) + + it('should not match unequal bigints', () => { + expect(match(bigIntOperators, 'is', 42n, [43n])).toBe(false) + }) + + it('should coerce strings to bigint', () => { + expect(match(bigIntOperators, 'is', 42n, ['42'])).toBe(true) + }) + }) + + describe('is_greater_than', () => { + it('should match when cell > filter', () => { + expect(match(bigIntOperators, 'is_greater_than', 10n, [5n])).toBe(true) + }) + + it('should not match when cell <= filter', () => { + expect(match(bigIntOperators, 'is_greater_than', 5n, [5n])).toBe(false) + }) + }) + + describe('is_between', () => { + it('should match when cell is in range', () => { + expect(match(bigIntOperators, 'is_between', 5n, [1n, 10n])).toBe(true) + }) + + it('should not match when cell is outside range', () => { + expect(match(bigIntOperators, 'is_between', 0n, [1n, 10n])).toBe(false) + }) + + it('should handle reversed range', () => { + expect(match(bigIntOperators, 'is_between', 5n, [10n, 1n])).toBe(true) + }) + }) + }) + + // ── Date Operators ────────────────────────────────────────── + + describe('dateOperators', () => { + it('should have 8 operators', () => { + expect(dateOperators.size).toBe(8) + }) + + it('should have correct defaults', () => { + expect(dateOperators.getDefault('single').id).toBe('is') + expect(dateOperators.getDefault('multiple').id).toBe('is_between') + }) + + const jan1 = new Date(2025, 0, 1, 12, 0, 0) + const jan2 = new Date(2025, 0, 2, 12, 0, 0) + const jan3 = new Date(2025, 0, 3, 12, 0, 0) + const jan1Morning = new Date(2025, 0, 1, 8, 0, 0) + + describe('is', () => { + it('should match same day', () => { + expect(match(dateOperators, 'is', jan1, [jan1Morning])).toBe(true) + }) + + it('should not match different day', () => { + expect(match(dateOperators, 'is', jan1, [jan2])).toBe(false) + }) + + it('should pass with non-Date filter', () => { + expect(match(dateOperators, 'is', jan1, ['not-a-date'])).toBe(true) + }) + + it('should not match non-Date cell', () => { + expect(match(dateOperators, 'is', 'not-a-date', [jan1])).toBe(false) + }) + }) + + describe('is_not', () => { + it('should match different day', () => { + expect(match(dateOperators, 'is_not', jan1, [jan2])).toBe(true) + }) + + it('should not match same day', () => { + expect(match(dateOperators, 'is_not', jan1, [jan1Morning])).toBe(false) + }) + }) + + describe('is_before', () => { + it('should match when cell is before filter date', () => { + expect(match(dateOperators, 'is_before', jan1, [jan2])).toBe(true) + }) + + it('should not match same day', () => { + expect(match(dateOperators, 'is_before', jan1, [jan1Morning])).toBe( + false, + ) + }) + + it('should not match when cell is after filter date', () => { + expect(match(dateOperators, 'is_before', jan2, [jan1])).toBe(false) + }) + }) + + describe('is_after', () => { + it('should match when cell is after filter date (end of day)', () => { + expect(match(dateOperators, 'is_after', jan3, [jan1])).toBe(true) + }) + + it('should not match same day', () => { + expect(match(dateOperators, 'is_after', jan1, [jan1Morning])).toBe( + false, + ) + }) + }) + + describe('is_on_or_after', () => { + it('should match same day', () => { + expect( + match(dateOperators, 'is_on_or_after', jan1, [jan1Morning]), + ).toBe(true) + }) + + it('should match after', () => { + expect(match(dateOperators, 'is_on_or_after', jan2, [jan1])).toBe(true) + }) + + it('should not match before', () => { + expect(match(dateOperators, 'is_on_or_after', jan1, [jan2])).toBe(false) + }) + }) + + describe('is_on_or_before', () => { + it('should match same day', () => { + expect( + match(dateOperators, 'is_on_or_before', jan1, [jan1Morning]), + ).toBe(true) + }) + + it('should match before', () => { + expect(match(dateOperators, 'is_on_or_before', jan1, [jan2])).toBe(true) + }) + + it('should not match after', () => { + expect(match(dateOperators, 'is_on_or_before', jan3, [jan1])).toBe( + false, + ) + }) + }) + + describe('is_between', () => { + it('should match when cell is in date range', () => { + expect(match(dateOperators, 'is_between', jan2, [jan1, jan3])).toBe( + true, + ) + }) + + it('should match boundary dates', () => { + expect(match(dateOperators, 'is_between', jan1, [jan1, jan3])).toBe( + true, + ) + expect(match(dateOperators, 'is_between', jan3, [jan1, jan3])).toBe( + true, + ) + }) + + it('should not match outside range', () => { + const dec31 = new Date(2024, 11, 31) + expect(match(dateOperators, 'is_between', dec31, [jan1, jan3])).toBe( + false, + ) + }) + + it('should handle reversed range', () => { + expect(match(dateOperators, 'is_between', jan2, [jan3, jan1])).toBe( + true, + ) + }) + + it('should pass with fewer than 2 filter values', () => { + expect(match(dateOperators, 'is_between', jan1, [jan1])).toBe(true) + expect(match(dateOperators, 'is_between', jan1, [])).toBe(true) + }) + }) + + describe('is_not_between', () => { + it('should match when cell is outside range', () => { + const dec31 = new Date(2024, 11, 31) + expect( + match(dateOperators, 'is_not_between', dec31, [jan1, jan3]), + ).toBe(true) + }) + + it('should not match when cell is in range', () => { + expect(match(dateOperators, 'is_not_between', jan2, [jan1, jan3])).toBe( + false, + ) + }) + }) + }) + + // ── Boolean Operators ─────────────────────────────────────── + + describe('booleanOperators', () => { + it('should have 2 operators', () => { + expect(booleanOperators.size).toBe(2) + }) + + describe('is', () => { + it('should match true === true', () => { + expect(match(booleanOperators, 'is', true, [true])).toBe(true) + }) + + it('should match false === false', () => { + expect(match(booleanOperators, 'is', false, [false])).toBe(true) + }) + + it('should not match true !== false', () => { + expect(match(booleanOperators, 'is', true, [false])).toBe(false) + }) + + it('should pass with empty filter', () => { + expect(match(booleanOperators, 'is', true, [])).toBe(true) + }) + }) + + describe('is_not', () => { + it('should match when different', () => { + expect(match(booleanOperators, 'is_not', true, [false])).toBe(true) + }) + + it('should not match when same', () => { + expect(match(booleanOperators, 'is_not', true, [true])).toBe(false) + }) + }) + }) + + // ── Default Operator Sets Map ───────────────────────────── + + describe('defaultOperatorSets', () => { + it('should have entries for all 7 built-in types', () => { + expect(defaultOperatorSets.text).toBe(textOperators) + expect(defaultOperatorSets.number).toBe(numberOperators) + expect(defaultOperatorSets.bigint).toBe(bigIntOperators) + expect(defaultOperatorSets.date).toBe(dateOperators) + expect(defaultOperatorSets.boolean).toBe(booleanOperators) + expect(defaultOperatorSets.option).toBe(optionOperators) + expect(defaultOperatorSets.multiOption).toBe(multiOptionOperators) + }) + + it('should have i18nKey on every operator across all sets', () => { + for (const [type, set] of Object.entries(defaultOperatorSets)) { + for (const op of set.all()) { + expect(op.i18nKey, `${type}/${op.id} missing i18nKey`).toBeDefined() + } + } + }) + + it('should have target set on every operator', () => { + for (const [type, set] of Object.entries(defaultOperatorSets)) { + for (const op of set.all()) { + expect( + ['single', 'multiple'].includes(op.target), + `${type}/${op.id} has invalid target: ${op.target}`, + ).toBe(true) + } + } + }) + + it('should have match functions on every operator', () => { + for (const [type, set] of Object.entries(defaultOperatorSets)) { + for (const op of set.all()) { + expect( + typeof op.match, + `${type}/${op.id} missing match function`, + ).toBe('function') + } + } + }) + + it('should have valid singular/plural references', () => { + for (const [type, set] of Object.entries(defaultOperatorSets)) { + for (const op of set.all()) { + if (op.singular) { + expect( + set.has(op.singular), + `${type}/${op.id} singular "${op.singular}" not found in set`, + ).toBe(true) + } + if (op.plural) { + expect( + set.has(op.plural), + `${type}/${op.id} plural "${op.plural}" not found in set`, + ).toBe(true) + } + } + } + }) + }) +}) diff --git a/packages/data-view/src/__tests__/order-fns.test.ts b/packages/data-view/src/__tests__/order-fns.test.ts new file mode 100644 index 00000000..379d9887 --- /dev/null +++ b/packages/data-view/src/__tests__/order-fns.test.ts @@ -0,0 +1,513 @@ +import { describe, expect, it } from 'vitest' +import { createColumnBuilder } from '../core/columns/index.js' +import type { ColumnOption, TCustomOrderFn } from '../core/types.js' +import { + applyOrderFns, + isBuiltInOrderFnName, + isBuiltInOrderFnTuple, + isCustomOrderFn, + isOrderDirection, + isOrderFnArg, + orderFns, +} from '../lib/order-fns.js' + +// Test data +const testOptions: ColumnOption[] = [ + { value: 'apple', label: 'Apple', count: 5 }, + { value: 'banana', label: 'Banana', count: 3 }, + { value: 'cherry', label: 'Cherry', count: 8 }, + { value: 'date', label: 'Date', count: 1 }, + { value: 'elderberry', label: 'Elderberry', count: 3 }, // Same count as banana +] + +type TestData = { + fruit: string +} + +const helper = createColumnBuilder() + +describe('lib/order-fns', () => { + describe('Built-in order functions', () => { + describe('count function', () => { + it('should sort by count ascending', () => { + const result = orderFns.count( + { value: 'a', label: 'A', count: 5 }, + { value: 'b', label: 'B', count: 3 }, + 'asc', + ) + expect(result).toBeGreaterThan(0) + }) + + it('should sort by count descending', () => { + const result = orderFns.count( + { value: 'a', label: 'A', count: 5 }, + { value: 'b', label: 'B', count: 3 }, + 'desc', + ) + expect(result).toBeLessThan(0) + }) + + it('should handle undefined count as 0', () => { + const result = orderFns.count( + { value: 'a', label: 'A' }, // No count property + { value: 'b', label: 'B', count: 3 }, + 'asc', + ) + expect(result).toBeLessThan(0) + }) + + it('should return 0 for equal counts', () => { + const result = orderFns.count( + { value: 'a', label: 'A', count: 5 }, + { value: 'b', label: 'B', count: 5 }, + 'asc', + ) + expect(result).toBe(0) + }) + }) + + describe('label function', () => { + it('should sort by label ascending', () => { + const result = orderFns.label( + { value: 'b', label: 'Banana' }, + { value: 'a', label: 'Apple' }, + 'asc', + ) + expect(result).toBeGreaterThan(0) // 'banana' > 'apple' + }) + + it('should sort by label descending', () => { + const result = orderFns.label( + { value: 'a', label: 'Apple' }, + { value: 'b', label: 'Banana' }, + 'desc', + ) + expect(result).toBeGreaterThan(0) // 'banana' > 'apple' (reversed) + }) + + it('should be case insensitive', () => { + const result = orderFns.label( + { value: 'a', label: 'APPLE' }, + { value: 'b', label: 'banana' }, + 'asc', + ) + expect(result).toBeLessThan(0) // 'apple' < 'banana' + }) + + it('should return 0 for equal labels', () => { + const result = orderFns.label( + { value: 'a', label: 'Apple' }, + { value: 'b', label: 'Apple' }, + 'asc', + ) + expect(result).toBe(0) + }) + }) + }) + + describe('Type guards', () => { + describe('isBuiltInOrderFnName', () => { + it('should return true for valid built-in function names', () => { + expect(isBuiltInOrderFnName('count')).toBe(true) + expect(isBuiltInOrderFnName('label')).toBe(true) + }) + + it('should return false for invalid function names', () => { + expect(isBuiltInOrderFnName('invalid')).toBe(false) + expect(isBuiltInOrderFnName('')).toBe(false) + expect(isBuiltInOrderFnName(123)).toBe(false) + expect(isBuiltInOrderFnName(null)).toBe(false) + expect(isBuiltInOrderFnName(undefined)).toBe(false) + }) + }) + + describe('isOrderDirection', () => { + it('should return true for valid directions', () => { + expect(isOrderDirection('asc')).toBe(true) + expect(isOrderDirection('desc')).toBe(true) + }) + + it('should return false for invalid directions', () => { + expect(isOrderDirection('ascending')).toBe(false) + expect(isOrderDirection('up')).toBe(false) + expect(isOrderDirection('')).toBe(false) + expect(isOrderDirection(123)).toBe(false) + expect(isOrderDirection(null)).toBe(false) + expect(isOrderDirection(undefined)).toBe(false) + }) + }) + + describe('isCustomOrderFn', () => { + const validCustomFn: TCustomOrderFn = (a, b) => + a.label.localeCompare(b.label) + const invalidFn = (a: any) => a // Wrong arity + + it('should return true for valid custom functions', () => { + expect(isCustomOrderFn(validCustomFn)).toBe(true) + }) + + it('should return false for functions with wrong arity', () => { + expect(isCustomOrderFn(invalidFn)).toBe(false) + expect(isCustomOrderFn(() => 0)).toBe(false) // 0 parameters + expect(isCustomOrderFn((_a: any, _b: any, _c: any) => 0)).toBe(false) // 3 parameters + }) + + it('should return false for non-functions', () => { + expect(isCustomOrderFn('not a function')).toBe(false) + expect(isCustomOrderFn(123)).toBe(false) + expect(isCustomOrderFn(null)).toBe(false) + expect(isCustomOrderFn(undefined)).toBe(false) + expect(isCustomOrderFn({})).toBe(false) + }) + }) + + describe('isBuiltInOrderFnTuple', () => { + it('should return true for valid tuples', () => { + expect(isBuiltInOrderFnTuple(['count', 'asc'])).toBe(true) + expect(isBuiltInOrderFnTuple(['label', 'desc'])).toBe(true) + }) + + it('should return false for invalid tuples', () => { + expect(isBuiltInOrderFnTuple(['invalid', 'asc'])).toBe(false) + expect(isBuiltInOrderFnTuple(['count', 'invalid'])).toBe(false) + expect(isBuiltInOrderFnTuple(['count'])).toBe(false) // Wrong length + expect(isBuiltInOrderFnTuple(['count', 'asc', 'extra'])).toBe(false) // Wrong length + expect(isBuiltInOrderFnTuple('not an array')).toBe(false) + expect(isBuiltInOrderFnTuple(null)).toBe(false) + expect(isBuiltInOrderFnTuple(undefined)).toBe(false) + }) + }) + + describe('isOrderFnArg', () => { + const validCustomFn: TCustomOrderFn = (a, b) => + a.label.localeCompare(b.label) + + it('should return true for valid built-in tuples', () => { + expect(isOrderFnArg(['count', 'asc'])).toBe(true) + expect(isOrderFnArg(['label', 'desc'])).toBe(true) + }) + + it('should return true for valid custom functions', () => { + expect(isOrderFnArg(validCustomFn)).toBe(true) + }) + + it('should return false for invalid arguments', () => { + expect(isOrderFnArg(['invalid', 'asc'])).toBe(false) + expect(isOrderFnArg('not valid')).toBe(false) + expect(isOrderFnArg(123)).toBe(false) + expect(isOrderFnArg(null)).toBe(false) + expect(isOrderFnArg(undefined)).toBe(false) + }) + }) + }) + + describe('applyOrderFns', () => { + it('should apply single order function', () => { + const orderFunctions = [ + (a: ColumnOption, b: ColumnOption) => orderFns.count(a, b, 'asc'), + ] + + const result = applyOrderFns(orderFunctions, [...testOptions]) + + expect(result.map((o) => o.value)).toEqual([ + 'date', // count: 1 + 'banana', // count: 3 + 'elderberry', // count: 3 + 'apple', // count: 5 + 'cherry', // count: 8 + ]) + }) + + it('should apply multiple order functions in sequence', () => { + const orderFunctions = [ + // First sort by count (asc) + (a: ColumnOption, b: ColumnOption) => orderFns.count(a, b, 'asc'), + // Then sort by label (asc) for ties + (a: ColumnOption, b: ColumnOption) => orderFns.label(a, b, 'asc'), + ] + + const result = applyOrderFns(orderFunctions, [...testOptions]) + + expect(result.map((o) => o.value)).toEqual([ + 'date', // count: 1 + 'banana', // count: 3, label: 'Banana' + 'elderberry', // count: 3, label: 'Elderberry' + 'apple', // count: 5 + 'cherry', // count: 8 + ]) + }) + + it('should handle empty order functions array', () => { + const result = applyOrderFns([], [...testOptions]) + + // Should return original order + expect(result.map((o) => o.value)).toEqual([ + 'apple', + 'banana', + 'cherry', + 'date', + 'elderberry', + ]) + }) + + it('should handle all equal comparisons', () => { + const sameOptions = [ + { value: 'a', label: 'Same', count: 5 }, + { value: 'b', label: 'Same', count: 5 }, + { value: 'c', label: 'Same', count: 5 }, + ] + + const orderFunctions = [ + (a: ColumnOption, b: ColumnOption) => orderFns.count(a, b, 'asc'), + (a: ColumnOption, b: ColumnOption) => orderFns.label(a, b, 'asc'), + ] + + const result = applyOrderFns(orderFunctions, sameOptions) + + // Should maintain original order when all comparisons are equal + expect(result.map((o) => o.value)).toEqual(['a', 'b', 'c']) + }) + + it('should work with custom order functions', () => { + const customOrderFn: TCustomOrderFn = (a, b) => { + // Sort by value length, then alphabetically + const lengthDiff = a.value.length - b.value.length + if (lengthDiff !== 0) return lengthDiff + return a.value.localeCompare(b.value) + } + + const orderFunctions = [customOrderFn] + const result = applyOrderFns(orderFunctions, [...testOptions]) + + expect(result.map((o) => o.value)).toEqual([ + 'date', // length: 4 + 'apple', // length: 5 + 'banana', // length: 6 + 'cherry', // length: 6 + 'elderberry', // length: 10 + ]) + }) + + it('should handle complex chaining scenario', () => { + const complexOptions: ColumnOption[] = [ + { value: 'a1', label: 'Zebra', count: 1 }, + { value: 'a2', label: 'Apple', count: 1 }, + { value: 'b1', label: 'Zebra', count: 2 }, + { value: 'b2', label: 'Apple', count: 2 }, + ] + + const orderFunctions = [ + // Sort by count desc + (a: ColumnOption, b: ColumnOption) => orderFns.count(a, b, 'desc'), + // Then by label asc + (a: ColumnOption, b: ColumnOption) => orderFns.label(a, b, 'asc'), + // Then by value asc (custom) + (a: ColumnOption, b: ColumnOption) => a.value.localeCompare(b.value), + ] + + const result = applyOrderFns(orderFunctions, complexOptions) + + expect(result.map((o) => o.value)).toEqual([ + 'b2', // count: 2, label: 'Apple', value: 'b2' + 'b1', // count: 2, label: 'Zebra', value: 'b1' + 'a2', // count: 1, label: 'Apple', value: 'a2' + 'a1', // count: 1, label: 'Zebra', value: 'a1' + ]) + }) + }) + + describe('Column config builder orderFn', () => { + it('should accept built-in function with direction as separate arguments', () => { + const config = helper + .option() + .id('test') + .accessor(() => 'test') + .displayName('Test') + .orderFn('count', 'asc') + .build() + + expect(config.orderFn).toBeDefined() + expect(config.orderFn!).toHaveLength(1) + + // Test the function works + const testOptions = [ + { value: 'a', label: 'A', count: 5 }, + { value: 'b', label: 'B', count: 3 }, + ] + const result = applyOrderFns(config.orderFn!, testOptions) + expect(result[0]!.value).toBe('b') // Lower count first + }) + + it('should accept custom function', () => { + const customFn: TCustomOrderFn = (a, b) => a.label.localeCompare(b.label) + + const config = helper + .option() + .id('test') + .accessor(() => 'test') + .displayName('Test') + .orderFn(customFn) + .build() + + expect(config.orderFn).toBeDefined() + expect(config.orderFn!).toHaveLength(1) + expect(config.orderFn![0]).toBe(customFn) + }) + + it('should accept multiple order functions as rest parameters', () => { + const customFn: TCustomOrderFn = (a, b) => a.value.localeCompare(b.value) + + const config = helper + .option() + .id('test') + .accessor(() => 'test') + .displayName('Test') + .orderFn(['count', 'desc'], customFn, ['label', 'asc']) + .build() + + expect(config.orderFn).toBeDefined() + expect(config.orderFn!).toHaveLength(3) + }) + + it('should throw error for invalid built-in function name', () => { + expect(() => { + helper + .option() + .id('test') + .accessor(() => 'test') + .displayName('Test') + // @ts-expect-error - testing invalid function name + .orderFn('invalid', 'asc') + .build() + }).toThrow() + }) + + it('should throw error for invalid direction', () => { + expect(() => { + helper + .option() + .id('test') + .accessor(() => 'test') + .displayName('Test') + // @ts-expect-error - testing invalid direction + .orderFn('count', 'invalid') + .build() + }).toThrow() + }) + + it('should throw error when used on non-option column', () => { + expect(() => { + helper + .text() + .id('test') + .accessor(() => 'test') + .displayName('Test') + .orderFn('count', 'asc') + .build() + }).toThrow( + 'orderFn() is only applicable to option or multiOption columns', + ) + }) + + it('should work with multiOption columns', () => { + const config = helper + .multiOption() + .id('test') + .accessor(() => ['test']) + .displayName('Test') + .orderFn('label', 'desc') + .build() + + expect(config.orderFn).toBeDefined() + expect(config.orderFn!).toHaveLength(1) + }) + + it('should handle mixed valid and invalid arguments', () => { + const validCustomFn: TCustomOrderFn = (a, b) => + a.value.localeCompare(b.value) + const invalidArg = 'not valid' + + expect(() => { + helper + .option() + .id('test') + .accessor(() => 'test') + .displayName('Test') + .orderFn(['count', 'asc'], validCustomFn, invalidArg as any) + .build() + }).toThrow('Invalid argument') + }) + }) + + describe('Real-world scenarios', () => { + it('should handle sorting priority list (count desc, then label asc)', () => { + const priorityOptions: ColumnOption[] = [ + { value: 'medium', label: 'Medium Priority', count: 15 }, + { value: 'high', label: 'High Priority', count: 5 }, + { value: 'low', label: 'Low Priority', count: 25 }, + { value: 'urgent', label: 'Urgent', count: 5 }, // Same count as high + { value: 'critical', label: 'Critical', count: 2 }, + ] + + const orderFunctions = [ + (a: ColumnOption, b: ColumnOption) => orderFns.count(a, b, 'desc'), + (a: ColumnOption, b: ColumnOption) => orderFns.label(a, b, 'asc'), + ] + + const result = applyOrderFns(orderFunctions, priorityOptions) + + expect(result.map((o) => o.value)).toEqual([ + 'low', // count: 25 + 'medium', // count: 15 + 'high', // count: 5, label: 'High Priority' + 'urgent', // count: 5, label: 'Urgent' + 'critical', // count: 2 + ]) + }) + + it('should handle alphabetical with case insensitive fallback', () => { + const mixedCaseOptions: ColumnOption[] = [ + { value: 'zebra', label: 'zebra' }, + { value: 'Apple', label: 'Apple' }, + { value: 'banana', label: 'BANANA' }, + { value: 'Cherry', label: 'Cherry' }, + ] + + const orderFunctions = [ + (a: ColumnOption, b: ColumnOption) => orderFns.label(a, b, 'asc'), + ] + + const result = applyOrderFns(orderFunctions, mixedCaseOptions) + + expect(result.map((o) => o.value)).toEqual([ + 'Apple', // 'apple' + 'banana', // 'banana' + 'Cherry', // 'cherry' + 'zebra', // 'zebra' + ]) + }) + + it('should handle empty or missing data gracefully', () => { + const incompleteOptions: ColumnOption[] = [ + { value: 'complete', label: 'Complete', count: 10 }, + { value: 'no-count', label: 'No Count' }, // No count property + { value: 'empty-label', label: '', count: 5 }, + { value: 'minimal', label: 'Minimal' }, // No count + ] + + const orderFunctions = [ + (a: ColumnOption, b: ColumnOption) => orderFns.count(a, b, 'desc'), + (a: ColumnOption, b: ColumnOption) => orderFns.label(a, b, 'asc'), + ] + + const result = applyOrderFns(orderFunctions, incompleteOptions) + + expect(result.map((o) => o.value)).toEqual([ + 'complete', // count: 10 + 'empty-label', // count: 5 + 'minimal', // count: 0 (undefined), label: 'Minimal' + 'no-count', // count: 0 (undefined), label: 'No Count' + ]) + }) + }) +}) diff --git a/packages/data-view/src/__tests__/resolve.test.ts b/packages/data-view/src/__tests__/resolve.test.ts new file mode 100644 index 00000000..d7958721 --- /dev/null +++ b/packages/data-view/src/__tests__/resolve.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' +import { createColumnBuilder } from '../core/columns/column-builder.js' +import { + parseFieldPath, + refineFieldPath, + resolveFieldRef, + resolveFieldRefs, +} from '../server/resolve.js' + +// ── Helpers ───────────────────────────────────────────────── + +type TestRow = { + title: string + created_at: Date + status: string + labels: string[] +} + +const c = createColumnBuilder() + +// ── parseFieldPath ────────────────────────────────────────── + +describe('server/resolve', () => { + describe('parseFieldPath', () => { + it('should parse a simple column name as direct', () => { + expect(parseFieldPath('title')).toEqual({ + kind: 'direct', + column: 'title', + }) + }) + + it('should parse an underscored column name as direct', () => { + expect(parseFieldPath('created_at')).toEqual({ + kind: 'direct', + column: 'created_at', + }) + }) + + it('should parse a dotted path as belongsTo by default', () => { + expect(parseFieldPath('status.name')).toEqual({ + kind: 'belongsTo', + relation: 'status', + column: 'name', + }) + }) + + it('should parse a dotted path with any relation as belongsTo', () => { + expect(parseFieldPath('labels.name')).toEqual({ + kind: 'belongsTo', + relation: 'labels', + column: 'name', + }) + }) + + it('should throw for a path starting with a dot', () => { + expect(() => parseFieldPath('.name')).toThrow( + 'Invalid field path ".name"', + ) + }) + + it('should throw for a path ending with a dot', () => { + expect(() => parseFieldPath('status.')).toThrow( + 'Invalid field path "status."', + ) + }) + + it('should throw for a standalone dot', () => { + expect(() => parseFieldPath('.')).toThrow('Invalid field path "."') + }) + }) + + // ── refineFieldPath ───────────────────────────────────────── + + describe('refineFieldPath', () => { + it('should convert belongsTo to hasMany when relation is in the set', () => { + const hasManyRelations = new Set(['labels']) + const path = parseFieldPath('labels.name') + + expect(refineFieldPath(path, hasManyRelations)).toEqual({ + kind: 'hasMany', + relation: 'labels', + column: 'name', + }) + }) + + it('should leave belongsTo unchanged when relation is not in the set', () => { + const hasManyRelations = new Set(['labels']) + const path = parseFieldPath('status.name') + + expect(refineFieldPath(path, hasManyRelations)).toEqual({ + kind: 'belongsTo', + relation: 'status', + column: 'name', + }) + }) + + it('should leave direct paths unchanged', () => { + const hasManyRelations = new Set(['labels']) + const path = parseFieldPath('title') + + expect(refineFieldPath(path, hasManyRelations)).toEqual({ + kind: 'direct', + column: 'title', + }) + }) + + it('should leave belongsTo unchanged when set is empty', () => { + const path = parseFieldPath('status.name') + + expect(refineFieldPath(path, new Set())).toEqual({ + kind: 'belongsTo', + relation: 'status', + column: 'name', + }) + }) + }) + + // ── resolveFieldRef ───────────────────────────────────────── + + describe('resolveFieldRef', () => { + it('should use column.id when .field() is not set', () => { + const col = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + + const ref = resolveFieldRef(col) + + expect(ref).toEqual({ + columnId: 'title', + type: 'text', + path: { kind: 'direct', column: 'title' }, + }) + }) + + it('should parse .field() when set to a simple column name', () => { + const col = c + .date() + .id('createdAt') + .accessor((r) => r.created_at) + .displayName('Created') + .field('created_at') + .build() + + const ref = resolveFieldRef(col) + + expect(ref).toEqual({ + columnId: 'createdAt', + type: 'date', + path: { kind: 'direct', column: 'created_at' }, + }) + }) + + it('should parse .field() when set to a dotted relation path', () => { + const col = c + .option() + .id('status') + .accessor((r) => r.status) + .displayName('Status') + .field('status.name') + .build() + + const ref = resolveFieldRef(col) + + expect(ref).toEqual({ + columnId: 'status', + type: 'option', + path: { kind: 'belongsTo', relation: 'status', column: 'name' }, + }) + }) + + it('should refine belongsTo to hasMany when hasManyRelations is provided', () => { + const col = c + .multiOption() + .id('labels') + .accessor((r) => r.labels) + .displayName('Labels') + .field('labels.name') + .build() + + const ref = resolveFieldRef(col, new Set(['labels'])) + + expect(ref).toEqual({ + columnId: 'labels', + type: 'multiOption', + path: { kind: 'hasMany', relation: 'labels', column: 'name' }, + }) + }) + + it('should not refine when hasManyRelations is not provided', () => { + const col = c + .multiOption() + .id('labels') + .accessor((r) => r.labels) + .displayName('Labels') + .field('labels.name') + .build() + + const ref = resolveFieldRef(col) + + expect(ref.path).toEqual({ + kind: 'belongsTo', + relation: 'labels', + column: 'name', + }) + }) + }) + + // ── resolveFieldRefs ──────────────────────────────────────── + + describe('resolveFieldRefs', () => { + it('should create a Map of all columns', () => { + const titleCol = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + + const statusCol = c + .option() + .id('status') + .accessor((r) => r.status) + .displayName('Status') + .field('status.name') + .build() + + const labelsCol = c + .multiOption() + .id('labels') + .accessor((r) => r.labels) + .displayName('Labels') + .field('labels.name') + .build() + + const map = resolveFieldRefs( + [titleCol, statusCol, labelsCol], + new Set(['labels']), + ) + + expect(map.size).toBe(3) + + expect(map.get('title')).toEqual({ + columnId: 'title', + type: 'text', + path: { kind: 'direct', column: 'title' }, + }) + + expect(map.get('status')).toEqual({ + columnId: 'status', + type: 'option', + path: { kind: 'belongsTo', relation: 'status', column: 'name' }, + }) + + expect(map.get('labels')).toEqual({ + columnId: 'labels', + type: 'multiOption', + path: { kind: 'hasMany', relation: 'labels', column: 'name' }, + }) + }) + + it('should work without hasManyRelations', () => { + const col = c + .text() + .id('title') + .accessor((r) => r.title) + .displayName('Title') + .build() + + const map = resolveFieldRefs([col]) + + expect(map.size).toBe(1) + expect(map.get('title')!.path.kind).toBe('direct') + }) + }) +}) diff --git a/packages/data-view/src/__tests__/serialize.test.ts b/packages/data-view/src/__tests__/serialize.test.ts new file mode 100644 index 00000000..fcb73c40 --- /dev/null +++ b/packages/data-view/src/__tests__/serialize.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from 'vitest' +import { builtInColumnTypes, defineColumnType } from '../core/column-types.js' +import { numberOperators } from '../core/operator-sets.js' +import type { DataViewState } from '../core/types.js' +import { deserializeView, serializeView } from '../lib/serialize.js' + +// ── Helpers ───────────────────────────────────────────────── + +const simpleView: DataViewState = { + filters: [ + { columnId: 'status', type: 'option', operator: 'is', values: ['active'] }, + { + columnId: 'name', + type: 'text', + operator: 'contains', + values: ['John'], + }, + ], + sort: [{ type: 'column', columnId: 'name', direction: 'desc' }], +} + +// ── serializeView / deserializeView ───────────────────────── + +describe('lib/serialize', () => { + // ── Round-trip with simple types ──────────────────────────── + + describe('round-trip with simple types', () => { + it('should round-trip a view with text and option filters', () => { + const encoded = serializeView(simpleView) + const decoded = deserializeView(encoded) + expect(decoded).toEqual(simpleView) + }) + + it('should round-trip a view with number filters', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'age', + type: 'number', + operator: 'is_between', + values: [18, 65], + }, + ], + sort: [], + } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded).toEqual(view) + }) + + it('should round-trip a view with boolean filter', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'active', + type: 'boolean', + operator: 'is', + values: [true], + }, + ], + sort: [], + } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded).toEqual(view) + }) + + it('should round-trip a view with multiOption filter', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'tags', + type: 'multiOption', + operator: 'include_any_of', + values: ['bug', 'feature'], + }, + ], + sort: [], + } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded).toEqual(view) + }) + }) + + // ── Date values ───────────────────────────────────────────── + + describe('date values', () => { + it('should round-trip date filter values with builtInColumnTypes', () => { + const date1 = new Date('2024-01-15T00:00:00.000Z') + const date2 = new Date('2024-06-30T00:00:00.000Z') + + const view: DataViewState = { + filters: [ + { + columnId: 'createdAt', + type: 'date', + operator: 'is_between', + values: [date1, date2], + }, + ], + sort: [], + } + + const encoded = serializeView(view, { + columnTypes: builtInColumnTypes, + }) + const decoded = deserializeView(encoded, { + columnTypes: builtInColumnTypes, + }) + + expect(decoded).not.toBeNull() + expect(decoded!.filters[0].values).toHaveLength(2) + expect(decoded!.filters[0].values[0]).toBeInstanceOf(Date) + expect(decoded!.filters[0].values[1]).toBeInstanceOf(Date) + expect((decoded!.filters[0].values[0] as Date).getTime()).toBe( + date1.getTime(), + ) + expect((decoded!.filters[0].values[1] as Date).getTime()).toBe( + date2.getTime(), + ) + }) + + it('should serialize date to ISO string', () => { + const date = new Date('2024-03-15T12:00:00.000Z') + const view: DataViewState = { + filters: [ + { + columnId: 'createdAt', + type: 'date', + operator: 'is', + values: [date], + }, + ], + sort: [], + } + + const encoded = serializeView(view, { + columnTypes: builtInColumnTypes, + }) + // Decode the base64 to check the JSON + const json = decodeURIComponent(atob(encoded)) + const parsed = JSON.parse(json) + expect(parsed.filters[0].values[0]).toBe('2024-03-15T12:00:00.000Z') + }) + }) + + // ── BigInt values ─────────────────────────────────────────── + + describe('bigint values', () => { + it('should round-trip bigint filter values with builtInColumnTypes', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'amount', + type: 'bigint', + operator: 'is_between', + values: [BigInt(100), BigInt(99999999999999)], + }, + ], + sort: [], + } + + const encoded = serializeView(view, { + columnTypes: builtInColumnTypes, + }) + const decoded = deserializeView(encoded, { + columnTypes: builtInColumnTypes, + }) + + expect(decoded).not.toBeNull() + expect(decoded!.filters[0].values).toHaveLength(2) + expect(decoded!.filters[0].values[0]).toBe(BigInt(100)) + expect(decoded!.filters[0].values[1]).toBe(BigInt(99999999999999)) + }) + + it('should serialize bigint to string', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'amount', + type: 'bigint', + operator: 'is', + values: [BigInt(42)], + }, + ], + sort: [], + } + + const encoded = serializeView(view, { + columnTypes: builtInColumnTypes, + }) + const json = decodeURIComponent(atob(encoded)) + const parsed = JSON.parse(json) + expect(parsed.filters[0].values[0]).toBe('42') + }) + }) + + // ── Custom column types ─────────────────────────────────── + + describe('custom column types', () => { + it('should round-trip with a custom column type serialize/deserialize', () => { + const currencyType = defineColumnType<{ + amount: number + currency: string + }>({ + id: 'currency', + operators: numberOperators, + serialize: (v) => ({ a: v.amount, c: v.currency }), + deserialize: (raw: unknown) => { + const r = raw as { a: number; c: string } + return { amount: r.a, currency: r.c } + }, + }) + + const columnTypes = { + ...builtInColumnTypes, + currency: currencyType, + } + + const view: DataViewState = { + filters: [ + { + columnId: 'price', + type: 'currency', + operator: 'equals', + values: [{ amount: 99.99, currency: 'USD' }], + }, + ], + sort: [], + } + + const encoded = serializeView(view, { columnTypes }) + const decoded = deserializeView(encoded, { columnTypes }) + + expect(decoded).not.toBeNull() + expect(decoded!.filters[0].values[0]).toEqual({ + amount: 99.99, + currency: 'USD', + }) + }) + }) + + // ── Sort state ────────────────────────────────────────────── + + describe('sort state', () => { + it('should round-trip column sort rules', () => { + const view: DataViewState = { + filters: [], + sort: [ + { type: 'column', columnId: 'name', direction: 'asc' }, + { type: 'column', columnId: 'age', direction: 'desc' }, + ], + } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded).toEqual(view) + }) + + it('should round-trip custom sort rules', () => { + const view: DataViewState = { + filters: [], + sort: [ + { type: 'custom', id: 'relevance', enabled: true }, + { type: 'column', columnId: 'name', direction: 'desc' }, + ], + } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded).toEqual(view) + }) + }) + + // ── Empty views ───────────────────────────────────────────── + + describe('empty views', () => { + it('should round-trip an empty view', () => { + const view: DataViewState = { filters: [], sort: [] } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded).toEqual(view) + }) + }) + + // ── Views with metadata ─────────────────────────────────── + + describe('views with metadata', () => { + it('should round-trip a view with id and name', () => { + const view: DataViewState = { + id: 'saved-view-1', + name: 'My Active Issues', + filters: [ + { + columnId: 'status', + type: 'option', + operator: 'is', + values: ['active'], + }, + ], + sort: [{ type: 'column', columnId: 'name', direction: 'asc' }], + } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded).toEqual(view) + expect(decoded!.id).toBe('saved-view-1') + expect(decoded!.name).toBe('My Active Issues') + }) + + it('should round-trip a view with only id', () => { + const view: DataViewState = { + id: 'v2', + filters: [], + sort: [], + } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded!.id).toBe('v2') + expect(decoded!.name).toBeUndefined() + }) + }) + + // ── Malformed input ───────────────────────────────────────── + + describe('malformed input', () => { + it('should return null for empty string', () => { + expect(deserializeView('')).toBeNull() + }) + + it('should return null for invalid base64', () => { + expect(deserializeView('not-valid-base64!!!')).toBeNull() + }) + + it('should return null for valid base64 but invalid JSON', () => { + const encoded = btoa(encodeURIComponent('not json')) + expect(deserializeView(encoded)).toBeNull() + }) + + it('should return null for valid JSON but invalid structure (no filters)', () => { + const encoded = btoa(encodeURIComponent(JSON.stringify({ sort: [] }))) + expect(deserializeView(encoded)).toBeNull() + }) + + it('should return null for valid JSON but invalid structure (no sort)', () => { + const encoded = btoa(encodeURIComponent(JSON.stringify({ filters: [] }))) + expect(deserializeView(encoded)).toBeNull() + }) + + it('should return null for non-object JSON', () => { + const encoded = btoa(encodeURIComponent(JSON.stringify('hello'))) + expect(deserializeView(encoded)).toBeNull() + }) + + it('should return null for null JSON', () => { + const encoded = btoa(encodeURIComponent(JSON.stringify(null))) + expect(deserializeView(encoded)).toBeNull() + }) + }) + + // ── No columnTypes (passthrough) ────────────────────────── + + describe('without columnTypes', () => { + it('should pass values through without transformation', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'createdAt', + type: 'date', + operator: 'is', + values: ['2024-01-01T00:00:00.000Z'], // already a string + }, + ], + sort: [], + } + const encoded = serializeView(view) + const decoded = deserializeView(encoded) + expect(decoded!.filters[0].values[0]).toBe('2024-01-01T00:00:00.000Z') + }) + }) + + // ── Special characters ──────────────────────────────────── + + describe('special characters', () => { + it('should handle filter values with special characters', () => { + const view: DataViewState = { + filters: [ + { + columnId: 'name', + type: 'text', + operator: 'contains', + values: ['Hello & "World"