"use client"
import * as React from "react"
import {
ArrowCounterClockwiseIcon,
ArrowsInLineHorizontalIcon,
ArrowsOutLineHorizontalIcon,
CaretDownIcon,
DatabaseIcon,
KanbanIcon,
MagnifyingGlassIcon,
PaintBucketIcon,
PlusIcon,
ShareNetworkIcon,
SlidersHorizontalIcon,
StackIcon,
} from "@phosphor-icons/react"
import { cn } from "cn"
import {
CellDisplay,
FieldIcon,
FilterBuilder,
getValue,
isComplete,
isEmpty,
makeView,
matches,
MenuChoice,
MenuChoices,
compare,
displayText,
Pill,
pillColor,
primaryText,
RecordSheet,
SAMPLE_TABLES,
SortMenu,
ToolbarButton,
TINT,
tint,
uid,
visibleFields,
type CellValue,
type FieldDef,
type Row,
type TableDef,
type ViewDef,
} from "@/components/blocks/airtable"
import {
Kanban,
KanbanBoard,
KanbanColumn,
KanbanColumnContent,
KanbanColumnHandle,
KanbanColumnHeader,
KanbanItem,
KanbanOverlay,
type KanbanDragEnd,
type KanbanDragStart,
type KanbanValue,
} from "@/components/ui/kanban"
import { ScrollFadeArea } from "@/components/ui/scroll-fade-area"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Empty, EmptyDescription } from "@/components/ui/empty"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Item } from "@/components/ui/item"
import { Switch } from "@/components/ui/switch"
/* Board state */
// Stack keys are option names. "" holds records with no value.
type Items = { [stack: string]: string[] }
type BoardState = {
tables: TableDef[]
view: ViewDef
stackBy: string
colorBy: string | null
items: Items
stackOrder: string[]
collapsed: string[]
}
export type AirtableKanbanProps = {
/** Base name shown in the header. */
name?: string
/** Tables in the base. Linked record fields can point between them. */
tables?: TableDef[]
/** The table shown on the board. Defaults to the first table. */
tableId?: string
/** Single select field that makes the stacks. Defaults to the first one. */
stackBy?: string
/** Single select field that colors the left edge of each card. */
colorBy?: string | null
/** Called with the board after every change. */
onChange?: (state: BoardState) => void
/** Saves the board to localStorage under this key. */
storageKey?: string
/** Header color. Defaults to the theme's primary color. */
accent?: string
accentForeground?: string
className?: string
}
function stackField(table: TableDef, id: string) {
return table.fields.find((f) => f.id === id && f.type === "singleSelect")
}
function stackKeys(field: FieldDef) {
return ["", ...(field.options ?? [])]
}
// Puts each record in the stack that matches its value, keeping the order
// the board already has and adding new records to the bottom.
function reconcile(items: Items, rows: Row[], field: FieldDef, keys: string[]): Items {
const valueOf = new Map(rows.map((r) => [r.id, String(r.cells[field.id] ?? "")]))
const next: Items = {}
const placed = new Set<string>()
for (const key of keys) {
next[key] = (items[key] ?? []).filter((id) => valueOf.get(id) === key && !placed.has(id))
next[key].forEach((id) => placed.add(id))
}
for (const r of rows) {
if (placed.has(r.id)) continue
const key = valueOf.get(r.id) ?? ""
;(next[key] ??= []).push(r.id)
}
return next
}
function sameItems(a: Items, b: Items) {
const ka = Object.keys(a)
return ka.length === Object.keys(b).length && ka.every((k) => a[k]?.join() === b[k]?.join())
}
function initialBoard(tables: TableDef[], tableId?: string, stackBy?: string, colorBy?: string | null): BoardState {
const table = tables.find((t) => t.id === tableId) ?? tables[0]
const field = (stackBy && stackField(table, stackBy)) || table.fields.find((f) => f.type === "singleSelect")
const keys = field ? stackKeys(field) : [""]
const hidden = table.fields.filter((f) => f.type === "date" || f.id === "city").map((f) => f.id)
return {
tables,
view: makeView(table, `${table.id}-kanban`, "Kanban", "kanban", { hidden }),
stackBy: field?.id ?? "",
colorBy: colorBy === undefined ? (field?.id ?? null) : colorBy,
items: field ? reconcile({}, table.rows, field, keys) : { "": table.rows.map((r) => r.id) },
stackOrder: keys,
collapsed: [],
}
}
function readSaved(key: string): BoardState | null {
try {
const saved = JSON.parse(window.localStorage.getItem(key) ?? "null")
return saved && Array.isArray(saved.tables) && saved.view && saved.items ? saved : null
} catch {
return null
}
}
/* Cards */
type CardProps = {
tables: TableDef[]
table: TableDef
row: Row
fields: FieldDef[]
color?: string
overlay?: boolean
}
const RecordCard = React.memo(function RecordCard({ tables, table, row, fields, color, overlay }: CardProps) {
return (
<Card
size="sm"
className={cn(
"relative gap-2.5 px-3 text-[13px] select-none",
color && "pl-4",
overlay && "rotate-2 cursor-grabbing shadow-xl ring-2 ring-sky-500/40"
)}
>
{color && <span className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />}
<span className="font-medium">{primaryText(table, row) || "Unnamed record"}</span>
{fields.map((f) => {
const value = getValue(tables, row, f)
return (
<span key={f.id} className="flex flex-col gap-1">
<span className="flex items-center gap-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
<FieldIcon type={f.type} className="size-3" />
{f.name}
</span>
<span className="flex min-h-5 min-w-0 items-center">
{isEmpty(value) ? (
<span className="text-muted-foreground">Empty</span>
) : (
<CellDisplay tables={tables} field={f} value={value} wrap />
)}
</span>
</span>
)
})}
</Card>
)
})
/* Stacks */
type StackProps = {
stack: string
field: FieldDef
ids: string[]
total: number
collapsed: boolean
children: React.ReactNode
onToggle: () => void
onAdd: () => void
onRename: (name: string) => void
}
function Stack({ stack, field, ids, total, collapsed, children, onToggle, onAdd, onRename }: StackProps) {
const [renaming, setRenaming] = React.useState(false)
// Only a stack the user just collapsed or expanded plays the swap.
const [swapped, setSwapped] = React.useState(false)
const toggle = () => {
setSwapped(true)
onToggle()
}
const label = stack ? (
<Pill color={pillColor(field, stack)}>{stack}</Pill>
) : (
<span className="font-medium text-muted-foreground">Uncategorized</span>
)
// One column for both states, so its width tweens (transitions.dev card
// resize). Uncategorized stays first, but cards can still be dropped on it.
return (
<KanbanColumn
value={stack}
disabled={stack === ""}
collapsed={collapsed}
className={cn(
"gap-0 overflow-hidden rounded-[calc(var(--ui-radius)+0.5rem)] bg-muted/70 p-0 data-over:ring-0",
collapsed ? "min-h-64" : "max-h-full"
)}
>
{collapsed ? (
<KanbanColumnHandle asChild>
<Button
variant="ghost"
aria-label={`Expand ${stack || "Uncategorized"}`}
onClick={toggle}
className={cn("h-auto w-11 flex-1 flex-col justify-start gap-3 rounded-[calc(var(--ui-radius)+0.5rem)] px-0 py-3", swapped && "t-swap-in")}
>
{stack ? (
<Badge
variant="secondary"
style={tint(pillColor(field, stack))}
// In vertical writing, px pads the long side and py the short one.
className={cn("h-auto rounded-full border-0 px-2 py-1 [writing-mode:vertical-rl]", TINT)}
>
{stack}
</Badge>
) : (
<span className="text-xs font-medium text-muted-foreground [writing-mode:vertical-rl]">Uncategorized</span>
)}
<span className="text-xs text-muted-foreground tabular-nums [writing-mode:vertical-rl]">
{total} {total === 1 ? "record" : "records"}
</span>
</Button>
</KanbanColumnHandle>
) : (
<div className={cn("flex min-h-0 w-[min(17rem,82vw)] flex-1 flex-col", swapped && "t-swap-in")}>
<KanbanColumnHandle asChild>
<KanbanColumnHeader className="px-3 pt-2.5 pb-1.5">
{renaming ? (
<form
className="flex-1"
onSubmit={(e) => {
e.preventDefault()
const value = String(new FormData(e.currentTarget).get("name") ?? "").trim()
if (value) onRename(value)
setRenaming(false)
}}
>
<Input
name="name"
autoFocus
defaultValue={stack}
className="h-7"
onBlur={(e) => e.currentTarget.form?.requestSubmit()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === "Escape") setRenaming(false)
}}
/>
</form>
) : (
<span className="flex min-w-0 items-center">{label}</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Stack options"
className="ml-auto"
onPointerDown={(e) => e.stopPropagation()}
>
<CaretDownIcon weight="bold" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onAdd}>
<PlusIcon weight="bold" />
Add record
</DropdownMenuItem>
{stack && (
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<StackIcon weight="duotone" />
Rename stack
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={toggle}>
<ArrowsInLineHorizontalIcon weight="bold" />
Collapse stack
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</KanbanColumnHeader>
</KanbanColumnHandle>
<ScrollFadeArea fade="y">
<KanbanColumnContent className="min-h-16 px-2 pt-1 pb-2">
{children}
{ids.length === 0 && (
<Empty className="gap-0 p-0 py-5">
<EmptyDescription className="text-xs">No records</EmptyDescription>
</Empty>
)}
</KanbanColumnContent>
</ScrollFadeArea>
<div className="grid grid-cols-3 items-center px-3 pb-2.5">
<span className="text-xs text-muted-foreground tabular-nums">
{total} {total === 1 ? "record" : "records"}
</span>
<Button
type="button"
size="icon-sm"
aria-label={`Add record to ${stack || "Uncategorized"}`}
className="mx-auto rounded-full"
onClick={onAdd}
>
<PlusIcon weight="bold" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Collapse stack"
className="ml-auto text-muted-foreground"
onClick={toggle}
>
<ArrowsInLineHorizontalIcon weight="bold" />
</Button>
</div>
</div>
)}
</KanbanColumn>
)
}
function NewStack({ onAdd }: { onAdd: (name: string) => void }) {
const [open, setOpen] = React.useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" className="h-11 w-56 shrink-0 self-start rounded-[calc(var(--ui-radius)+0.5rem)] bg-muted/70 font-normal">
<PlusIcon weight="bold" />
New stack
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3">
<form
className="flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault()
const value = String(new FormData(e.currentTarget).get("name") ?? "").trim()
if (value) onAdd(value)
setOpen(false)
}}
>
<Input name="name" autoFocus placeholder="Stack name" />
<Button type="submit" size="sm" className="self-end">
Create stack
</Button>
</form>
</PopoverContent>
</Popover>
)
}
/* The board */
export function AirtableKanban({
name = "Gelato Distribution",
tables: initialTables = SAMPLE_TABLES,
tableId,
stackBy,
colorBy,
onChange,
storageKey,
accent,
accentForeground,
className,
}: AirtableKanbanProps) {
const initial = React.useMemo(
() => initialBoard(initialTables, tableId, stackBy, colorBy),
[initialTables, tableId, stackBy, colorBy]
)
const [board, setBoard] = React.useState(initial)
const [ready, setReady] = React.useState(!storageKey)
const [query, setQuery] = React.useState("")
const [expanded, setExpanded] = React.useState<string | null>(null)
const [active, setActive] = React.useState<{ id: string; type: "card" | "stack" } | null>(null)
const changed = React.useRef(false)
const onChangeRef = React.useRef(onChange)
onChangeRef.current = onChange
const { tables, view, items, stackOrder, collapsed } = board
const table = tables.find((t) => t.id === view.tableId) ?? tables[0]
const field = stackField(table, board.stackBy)
const colorField = board.colorBy ? stackField(table, board.colorBy) : undefined
const rowsById = React.useMemo(() => new Map(table.rows.map((r) => [r.id, r])), [table.rows])
const cardFields = visibleFields(table, view)
.slice(1)
.filter((f) => f.id !== field?.id)
React.useEffect(() => {
if (!storageKey) return
const saved = readSaved(storageKey)
if (saved) setBoard(saved)
setReady(true)
}, [storageKey])
React.useEffect(() => {
if (!ready) return
if (storageKey) {
try {
window.localStorage.setItem(storageKey, JSON.stringify(board))
} catch {
// Storage can be full or blocked. The board still works without it.
}
}
if (changed.current) onChangeRef.current?.(board)
changed.current = true
}, [board, storageKey, ready])
// Keep stacks in step with record values, except mid drag, when a card is
// allowed to sit in a stack its value does not match yet.
React.useEffect(() => {
if (active || !field) return
const keys = [...stackOrder.filter((k) => k === "" || field.options?.includes(k)), ...(field.options ?? []).filter((o) => !stackOrder.includes(o))]
const next = reconcile(items, table.rows, field, keys)
if (!sameItems(next, items) || keys.join() !== stackOrder.join())
setBoard((b) => ({ ...b, items: next, stackOrder: keys }))
}, [active, field, items, stackOrder, table.rows])
const patch = (p: Partial<BoardState> | ((b: BoardState) => Partial<BoardState>)) =>
setBoard((b) => ({ ...b, ...(typeof p === "function" ? p(b) : p) }))
const updateTable = (fn: (t: TableDef) => TableDef) =>
patch((b) => ({ tables: b.tables.map((t) => (t.id === table.id ? fn(t) : t)) }))
const setCell = (rowId: string, fieldId: string, v: CellValue) =>
updateTable((t) => ({
...t,
rows: t.rows.map((r) => (r.id === rowId ? { ...r, cells: { ...r.cells, [fieldId]: v } } : r)),
}))
const updateView = (p: Partial<ViewDef> | ((v: ViewDef) => Partial<ViewDef>)) =>
patch((b) => ({ view: { ...b.view, ...(typeof p === "function" ? p(b.view) : p) } }))
const addRecord = (stack: string) => {
if (!field) return
const id = `${table.id}-${uid()}`
updateTable((t) => ({ ...t, rows: [...t.rows, { id, cells: { [field.id]: stack || null } }] }))
patch((b) => ({ items: { ...b.items, [stack]: [...(b.items[stack] ?? []), id] } }))
setExpanded(id)
}
const deleteRecord = (id: string) => {
updateTable((t) => ({ ...t, rows: t.rows.filter((r) => r.id !== id) }))
}
const setOptions = (fn: (options: string[]) => string[], rename?: [string, string]) => {
if (!field) return
updateTable((t) => ({
...t,
fields: t.fields.map((f) => (f.id === field.id ? { ...f, options: fn(f.options ?? []) } : f)),
rows: rename
? t.rows.map((r) =>
r.cells[field.id] === rename[0] ? { ...r, cells: { ...r.cells, [field.id]: rename[1] } } : r
)
: t.rows,
}))
}
const addStack = (name: string) => {
if (!field || field.options?.includes(name)) return
setOptions((o) => [...o, name])
patch((b) => ({ stackOrder: [...b.stackOrder, name], items: { ...b.items, [name]: [] } }))
}
const renameStack = (from: string, to: string) => {
if (!field || from === to || field.options?.includes(to)) return
setOptions((o) => o.map((x) => (x === from ? to : x)), [from, to])
patch((b) => {
const { [from]: moved = [], ...rest } = b.items
return {
stackOrder: b.stackOrder.map((k) => (k === from ? to : k)),
items: { ...rest, [to]: moved },
collapsed: b.collapsed.map((k) => (k === from ? to : k)),
}
})
}
const changeStackBy = (id: string) => {
const next = initialBoard(tables, table.id, id, board.colorBy)
patch({ stackBy: id, items: next.items, stackOrder: next.stackOrder, collapsed: [] })
}
const reset = () => {
if (storageKey) {
try {
window.localStorage.removeItem(storageKey)
} catch {}
}
setBoard(initial)
setExpanded(null)
}
/* What each stack shows: filters, search and sorts on top of the board order */
const q = query.trim().toLowerCase()
const conds = view.filters.filter(isComplete)
const shows = (r: Row) =>
(!conds.length ||
(view.conjunction === "and"
? conds.every((c) => matches(tables, table, r, c))
: conds.some((c) => matches(tables, table, r, c)))) &&
(!q || table.fields.some((f) => displayText(tables, f, getValue(tables, r, f)).toLowerCase().includes(q)))
const visibleIds = (stack: string) => {
const rows = (items[stack] ?? []).map((id) => rowsById.get(id)).filter((r): r is Row => !!r && shows(r))
if (view.sorts.length && !active)
rows.sort((a, b) => {
for (const s of view.sorts) {
const f = table.fields.find((x) => x.id === s.field)
const c = f ? compare(tables, f, a, b) : 0
if (c) return s.dir === "asc" ? c : -c
}
return 0
})
return rows.map((r) => r.id)
}
/* Drag and drop. The Kanban primitive moves the ids, the board keeps values in step. */
const sortedAll = (stack: string, ids: string[]) => {
const shown = visibleIds(stack)
return [...shown, ...ids.filter((id) => !shown.includes(id))]
}
// The board shows the records that pass filters and search. Hidden records
// keep their place at the end of their stack.
const boardValue: KanbanValue<string> = Object.fromEntries(stackOrder.map((k) => [k, visibleIds(k)]))
const onBoardChange = (next: KanbanValue<string>) =>
patch((b) => {
const placed = new Set(Object.values(next).flat())
return {
items: Object.fromEntries(
Object.keys(next).map((k) => [k, [...next[k], ...(b.items[k] ?? []).filter((id) => !placed.has(id))]])
),
stackOrder: Object.keys(next),
}
})
const onDragStart = (start: KanbanDragStart) => {
// Dragging keeps what you see: a sorted stack becomes the manual order.
if (start.type === "item" && view.sorts.length) {
patch((b) => ({
items: Object.fromEntries(Object.keys(b.items).map((k) => [k, sortedAll(k, b.items[k])])),
view: { ...b.view, sorts: [] },
}))
}
setActive({ id: start.id, type: start.type === "column" ? "stack" : "card" })
}
// A card dropped in another stack takes that stack's value.
const onDragEnd = (end: KanbanDragEnd<string>) => {
setActive(null)
if (end.type === "item" && field && end.to !== end.from) setCell(end.id, field.id, end.to || null)
}
const cardColor = (r: Row) =>
colorField && !isEmpty(r.cells[colorField.id]) ? pillColor(colorField, String(r.cells[colorField.id])) : undefined
// Uncategorized always shows, like Airtable, so the board never shifts mid drag.
const shown = stackOrder
const order = shown.flatMap((k) => (collapsed.includes(k) ? [] : visibleIds(k)))
const selects = table.fields.filter((f) => f.type === "singleSelect")
return (
<Card
className={cn("w-full gap-0 py-0 text-sm", className)}
style={
{
"--at-accent": accent ?? "var(--primary)",
"--at-accent-fg": accentForeground ?? (accent ? "white" : "var(--primary-foreground)"),
} as React.CSSProperties
}
>
{/* Base header */}
<div className="flex items-center gap-2 bg-(--at-accent) px-3 py-2 text-(--at-accent-fg)">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[color-mix(in_oklab,var(--at-accent-fg)_15%,transparent)]">
<DatabaseIcon weight="duotone" className="size-4" />
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="min-w-0 font-semibold"
style={{ "--primary": "var(--at-accent-fg)" } as React.CSSProperties}
>
<span className="truncate">{name}</span>
<CaretDownIcon weight="bold" className="opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>{name}</DropdownMenuLabel>
<DropdownMenuItem onSelect={reset}>
<ArrowCounterClockwiseIcon weight="bold" />
Reset board
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="hidden truncate opacity-70 sm:inline">/ {table.name}</span>
<div className="ml-auto" style={{ "--primary": "var(--at-accent-fg)" } as React.CSSProperties}>
<Button type="button" size="sm" variant="secondary">
<ShareNetworkIcon weight="duotone" />
<span className="hidden sm:inline">Share view</span>
</Button>
</div>
</div>
{/* Every board control lives in this one row */}
<div className="flex items-center gap-1 overflow-x-auto border-b px-2 py-1.5 [scrollbar-width:none]">
<span className="flex shrink-0 items-center gap-1.5 px-2 font-medium">
<KanbanIcon weight="duotone" className="size-4 text-emerald-600 dark:text-emerald-400" />
{view.name}
</span>
<div className="mx-1 h-5 w-px shrink-0 bg-border" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton icon={StackIcon} label={field ? `Stacked by ${field.name}` : "Stack by"} tint="" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Stack records by</DropdownMenuLabel>
<MenuChoices value={board.stackBy} onValueChange={changeStackBy}>
{selects.map((f) => (
<MenuChoice key={f.id} value={f.id}>
{f.name}
</MenuChoice>
))}
</MenuChoices>
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger asChild>
<ToolbarButton icon={SlidersHorizontalIcon} weight="bold" label="Customize cards" tint="" />
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">Fields on cards</p>
{table.fields
.slice(1)
.filter((f) => f.id !== field?.id)
.map((f) => (
<Item key={f.id} asChild size="xs" className="flex-nowrap rounded-md border-0 px-2 py-1.5 hover:bg-muted">
<label>
<Switch
checked={!view.hidden.includes(f.id)}
onCheckedChange={(on) =>
updateView((v) => ({ hidden: on ? v.hidden.filter((h) => h !== f.id) : [...v.hidden, f.id] }))
}
/>
<FieldIcon type={f.type} className="text-muted-foreground" />
<span className="truncate">{f.name}</span>
</label>
</Item>
))}
</PopoverContent>
</Popover>
<FilterBuilder tables={tables} table={table} view={view} update={updateView} />
<SortMenu table={table} view={view} update={updateView} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
icon={PaintBucketIcon}
label="Color"
active={!!colorField}
tint="bg-amber-500/15 text-amber-900 hover:bg-amber-500/20 dark:text-amber-100"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Color cards by</DropdownMenuLabel>
<MenuChoices
value={board.colorBy ?? "none"}
onValueChange={(v) => patch({ colorBy: v === "none" ? null : v })}
>
<MenuChoice value="none">No color</MenuChoice>
{selects.map((f) => (
<MenuChoice key={f.id} value={f.id}>
{f.name}
</MenuChoice>
))}
</MenuChoices>
{collapsed.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => patch({ collapsed: [] })}>
<ArrowsOutLineHorizontalIcon weight="bold" />
Expand all stacks
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
<InputGroup className="w-32 lg:w-48">
<InputGroupAddon>
<MagnifyingGlassIcon weight="duotone" />
</InputGroupAddon>
<InputGroupInput placeholder="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
</InputGroup>
</div>
</div>
{field ? (
<Kanban
value={boardValue}
onValueChange={onBoardChange}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={() => setActive(null)}
>
<KanbanBoard className="h-[36rem] bg-muted/30 p-3">
{shown.map((stack) => {
const ids = boardValue[stack]
return (
<Stack
key={stack || "uncategorized"}
stack={stack}
field={field}
ids={ids}
total={ids.length}
// An empty Uncategorized stack stays narrow, even while a card is over it.
collapsed={
(collapsed.includes(stack) ||
(stack === "" && !(items[""] ?? []).some((id) => id !== active?.id))) &&
!(active?.type === "stack" && active.id === stack)
}
onToggle={() =>
patch((b) => ({
collapsed: b.collapsed.includes(stack)
? b.collapsed.filter((k) => k !== stack)
: [...b.collapsed, stack],
}))
}
onAdd={() => addRecord(stack)}
onRename={(to) => renameStack(stack, to)}
>
{ids.map((id) => (
<KanbanItem
key={id}
value={id}
asHandle
className="focus-visible:ring-sky-500"
onClick={() => setExpanded(id)}
onKeyDown={(e) => e.key === "Enter" && setExpanded(id)}
>
<RecordCard
tables={tables}
table={table}
row={rowsById.get(id)!}
fields={cardFields}
color={cardColor(rowsById.get(id)!)}
/>
</KanbanItem>
))}
</Stack>
)
})}
<NewStack onAdd={addStack} />
</KanbanBoard>
<KanbanOverlay>
{(drag) => {
if (drag.type === "item") {
const row = rowsById.get(drag.id)
return row ? (
<div className="w-[min(16rem,78vw)]">
<RecordCard tables={tables} table={table} row={row} fields={cardFields} color={cardColor(row)} overlay />
</div>
) : null
}
return (
<div className="flex h-24 w-[min(17rem,82vw)] items-start rotate-1 rounded-xl bg-muted p-3 shadow-xl ring-2 ring-sky-500/40">
{drag.id ? <Pill color={pillColor(field, drag.id)}>{drag.id}</Pill> : null}
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{(boardValue[drag.id] ?? []).length} records
</span>
</div>
)
}}
</KanbanOverlay>
</Kanban>
) : (
<Empty className="p-6">
<EmptyDescription>Add a single select field to stack records.</EmptyDescription>
</Empty>
)}
<RecordSheet
tables={tables}
table={table}
rowId={expanded}
order={order}
onNavigate={setExpanded}
onClose={() => setExpanded(null)}
setCell={setCell}
onDelete={deleteRecord}
/>
</Card>
)
}