370 lines
10 KiB
TypeScript
370 lines
10 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import {
|
||
|
|
ArrowDown,
|
||
|
|
ArrowUp,
|
||
|
|
ArrowUpDown,
|
||
|
|
ChevronDown,
|
||
|
|
ChevronRight,
|
||
|
|
ListTodo,
|
||
|
|
} from "lucide-react";
|
||
|
|
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
import { Separator } from "@/components/ui/separator";
|
||
|
|
import {
|
||
|
|
TooltipProvider,
|
||
|
|
} from "@/components/ui/tooltip";
|
||
|
|
import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data";
|
||
|
|
import { useViewData } from "@/lib/hooks/use-view-data";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
import {
|
||
|
|
getDueDateValue,
|
||
|
|
getPriorityValue,
|
||
|
|
ListItem,
|
||
|
|
LIST_ROW_GRID,
|
||
|
|
} from "./list-item";
|
||
|
|
|
||
|
|
type SortField = "status" | "title" | "assignees" | "priority" | "dueDate";
|
||
|
|
|
||
|
|
function groupByField(
|
||
|
|
objects: ViewObject[],
|
||
|
|
groupBy: string,
|
||
|
|
): Record<string, ViewObject[]> {
|
||
|
|
const groups: Record<string, ViewObject[]> = {};
|
||
|
|
for (const obj of objects) {
|
||
|
|
const key = String(
|
||
|
|
(obj as unknown as Record<string, unknown>)[groupBy] ?? "No Value",
|
||
|
|
);
|
||
|
|
if (!groups[key]) groups[key] = [];
|
||
|
|
groups[key].push(obj);
|
||
|
|
}
|
||
|
|
return groups;
|
||
|
|
}
|
||
|
|
|
||
|
|
function rankPriority(p: string): number {
|
||
|
|
const order = ["High", "Medium", "Low"];
|
||
|
|
const i = order.indexOf(p);
|
||
|
|
return i === -1 ? 999 : i;
|
||
|
|
}
|
||
|
|
|
||
|
|
function firstAssigneeName(o: ViewObject): string {
|
||
|
|
return o.assignees?.[0]?.user?.name ?? "";
|
||
|
|
}
|
||
|
|
|
||
|
|
function compareDue(a: string, b: string): number {
|
||
|
|
if (a === "—" && b === "—") return 0;
|
||
|
|
if (a === "—") return 1;
|
||
|
|
if (b === "—") return -1;
|
||
|
|
const ta = new Date(a).getTime();
|
||
|
|
const tb = new Date(b).getTime();
|
||
|
|
if (Number.isNaN(ta) || Number.isNaN(tb)) return a.localeCompare(b);
|
||
|
|
return ta - tb;
|
||
|
|
}
|
||
|
|
|
||
|
|
function sortItemsByField(items: ViewObject[], sort: ViewSort): ViewObject[] {
|
||
|
|
const dir = sort.direction === "asc" ? 1 : -1;
|
||
|
|
const copy = [...items];
|
||
|
|
copy.sort((a, b) => {
|
||
|
|
let cmp = 0;
|
||
|
|
switch (sort.field) {
|
||
|
|
case "priority": {
|
||
|
|
const pa = getPriorityValue(a);
|
||
|
|
const pb = getPriorityValue(b);
|
||
|
|
cmp = rankPriority(pa) - rankPriority(pb);
|
||
|
|
if (cmp === 0) cmp = pa.localeCompare(pb);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case "dueDate":
|
||
|
|
cmp = compareDue(getDueDateValue(a), getDueDateValue(b));
|
||
|
|
break;
|
||
|
|
case "assignees":
|
||
|
|
cmp = firstAssigneeName(a).localeCompare(firstAssigneeName(b));
|
||
|
|
break;
|
||
|
|
case "title":
|
||
|
|
cmp = a.title.localeCompare(b.title);
|
||
|
|
break;
|
||
|
|
case "status":
|
||
|
|
cmp = String(a.status ?? "").localeCompare(String(b.status ?? ""));
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
cmp = 0;
|
||
|
|
}
|
||
|
|
return cmp * dir;
|
||
|
|
});
|
||
|
|
return copy;
|
||
|
|
}
|
||
|
|
|
||
|
|
function refineSort(items: ViewObject[], sorts: ViewSort[]): ViewObject[] {
|
||
|
|
if (sorts.length === 0) return items;
|
||
|
|
const s = sorts[0];
|
||
|
|
if (s.field === "title" || s.field === "status") {
|
||
|
|
return items;
|
||
|
|
}
|
||
|
|
return sortItemsByField(items, s);
|
||
|
|
}
|
||
|
|
|
||
|
|
function nextSort(field: SortField, sorts: ViewSort[]): ViewSort[] {
|
||
|
|
const first = sorts[0];
|
||
|
|
if (first?.field === field) {
|
||
|
|
return [{ field, direction: first.direction === "asc" ? "desc" : "asc" }];
|
||
|
|
}
|
||
|
|
return [{ field, direction: "asc" }];
|
||
|
|
}
|
||
|
|
|
||
|
|
function SortHeaderButton({
|
||
|
|
label,
|
||
|
|
field,
|
||
|
|
sorts,
|
||
|
|
onSort,
|
||
|
|
}: {
|
||
|
|
label: string;
|
||
|
|
field: SortField;
|
||
|
|
sorts: ViewSort[];
|
||
|
|
onSort: (field: SortField) => void;
|
||
|
|
}) {
|
||
|
|
const active = sorts[0]?.field === field;
|
||
|
|
const dir = active ? sorts[0].direction : null;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => onSort(field)}
|
||
|
|
className={cn(
|
||
|
|
"inline-flex items-center gap-1 truncate text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground",
|
||
|
|
active && "text-foreground",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<span className="truncate">{label}</span>
|
||
|
|
{active ? (
|
||
|
|
dir === "asc" ? (
|
||
|
|
<ArrowUp className="h-3 w-3 shrink-0 opacity-80" />
|
||
|
|
) : (
|
||
|
|
<ArrowDown className="h-3 w-3 shrink-0 opacity-80" />
|
||
|
|
)
|
||
|
|
) : (
|
||
|
|
<ArrowUpDown className="h-3 w-3 shrink-0 opacity-40" />
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ListSkeleton() {
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col">
|
||
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
||
|
|
<div
|
||
|
|
key={i}
|
||
|
|
className={cn(
|
||
|
|
LIST_ROW_GRID,
|
||
|
|
"h-9 border-b border-border/40",
|
||
|
|
i % 2 === 1 && "bg-muted/15",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="flex justify-center">
|
||
|
|
<div className="h-3.5 w-3.5 animate-pulse rounded bg-muted" />
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-center">
|
||
|
|
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-muted" />
|
||
|
|
</div>
|
||
|
|
<div className="h-3 animate-pulse rounded bg-muted/80" />
|
||
|
|
<div className="flex gap-1">
|
||
|
|
<div className="h-6 w-6 animate-pulse rounded-full bg-muted" />
|
||
|
|
</div>
|
||
|
|
<div className="h-5 w-14 animate-pulse rounded-full bg-muted" />
|
||
|
|
<div className="h-3 w-16 animate-pulse rounded bg-muted/80" />
|
||
|
|
<div />
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function EmptyState() {
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col items-center justify-center gap-4 px-6 py-20 text-center">
|
||
|
|
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-dashed border-border bg-muted/30">
|
||
|
|
<ListTodo className="h-10 w-10 text-muted-foreground/70" strokeWidth={1.25} />
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
<p className="text-sm font-medium text-foreground">No items yet</p>
|
||
|
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||
|
|
Create a task or adjust filters to see work here.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ListViewProps {
|
||
|
|
config: ViewConfig;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ListView({ config }: ListViewProps) {
|
||
|
|
const [sorts, setSorts] = React.useState<ViewSort[]>(config.sorts);
|
||
|
|
React.useEffect(() => {
|
||
|
|
setSorts(config.sorts);
|
||
|
|
}, [config.sorts]);
|
||
|
|
|
||
|
|
const effectiveConfig = React.useMemo(
|
||
|
|
() => ({ ...config, sorts }),
|
||
|
|
[config, sorts],
|
||
|
|
);
|
||
|
|
|
||
|
|
const { items, isLoading, total } = useViewData(effectiveConfig);
|
||
|
|
|
||
|
|
const displayItems = React.useMemo(
|
||
|
|
() => refineSort(items, sorts),
|
||
|
|
[items, sorts],
|
||
|
|
);
|
||
|
|
|
||
|
|
const displayGrouped = React.useMemo(() => {
|
||
|
|
if (!config.groupBy) return null;
|
||
|
|
return groupByField(displayItems, config.groupBy);
|
||
|
|
}, [displayItems, config.groupBy]);
|
||
|
|
|
||
|
|
const [selected, setSelected] = React.useState<Set<string>>(new Set());
|
||
|
|
const toggleSelect = React.useCallback((id: string) => {
|
||
|
|
setSelected((prev) => {
|
||
|
|
const next = new Set(prev);
|
||
|
|
if (next.has(id)) next.delete(id);
|
||
|
|
else next.add(id);
|
||
|
|
return next;
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
|
||
|
|
const toggleGroup = React.useCallback((key: string) => {
|
||
|
|
setCollapsed((c) => ({ ...c, [key]: !c[key] }));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const onHeaderSort = React.useCallback((field: SortField) => {
|
||
|
|
setSorts((s) => nextSort(field, s));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const isGrouped = Boolean(config.groupBy);
|
||
|
|
|
||
|
|
const headerRow = (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
LIST_ROW_GRID,
|
||
|
|
"sticky top-0 z-10 h-9 shrink-0 border-b border-border bg-background/95 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div />
|
||
|
|
<SortHeaderButton
|
||
|
|
label="Status"
|
||
|
|
field="status"
|
||
|
|
sorts={sorts}
|
||
|
|
onSort={onHeaderSort}
|
||
|
|
/>
|
||
|
|
<SortHeaderButton
|
||
|
|
label="Title"
|
||
|
|
field="title"
|
||
|
|
sorts={sorts}
|
||
|
|
onSort={onHeaderSort}
|
||
|
|
/>
|
||
|
|
<SortHeaderButton
|
||
|
|
label="Assignees"
|
||
|
|
field="assignees"
|
||
|
|
sorts={sorts}
|
||
|
|
onSort={onHeaderSort}
|
||
|
|
/>
|
||
|
|
<SortHeaderButton
|
||
|
|
label="Priority"
|
||
|
|
field="priority"
|
||
|
|
sorts={sorts}
|
||
|
|
onSort={onHeaderSort}
|
||
|
|
/>
|
||
|
|
<SortHeaderButton
|
||
|
|
label="Due date"
|
||
|
|
field="dueDate"
|
||
|
|
sorts={sorts}
|
||
|
|
onSort={onHeaderSort}
|
||
|
|
/>
|
||
|
|
<div />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
let body: React.ReactNode = null;
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
body = <ListSkeleton />;
|
||
|
|
} else if (total === 0) {
|
||
|
|
body = <EmptyState />;
|
||
|
|
} else if (isGrouped && displayGrouped) {
|
||
|
|
const keys = Object.keys(displayGrouped).sort((a, b) =>
|
||
|
|
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
||
|
|
);
|
||
|
|
body = (
|
||
|
|
<div className="flex flex-col">
|
||
|
|
{keys.map((groupKey, gi) => {
|
||
|
|
const groupItems = displayGrouped[groupKey] ?? [];
|
||
|
|
const expanded = !collapsed[groupKey];
|
||
|
|
return (
|
||
|
|
<div key={groupKey} className="border-b border-border/50">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => toggleGroup(groupKey)}
|
||
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-accent/40"
|
||
|
|
>
|
||
|
|
{expanded ? (
|
||
|
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
|
|
) : (
|
||
|
|
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||
|
|
)}
|
||
|
|
<span className="text-sm font-semibold text-foreground">
|
||
|
|
{groupKey}
|
||
|
|
</span>
|
||
|
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-semibold">
|
||
|
|
{groupItems.length}
|
||
|
|
</Badge>
|
||
|
|
</button>
|
||
|
|
{expanded && (
|
||
|
|
<div>
|
||
|
|
{groupItems.map((obj, i) => (
|
||
|
|
<ListItem
|
||
|
|
key={obj.id}
|
||
|
|
object={obj}
|
||
|
|
onSelect={toggleSelect}
|
||
|
|
selected={selected.has(obj.id)}
|
||
|
|
rowIndex={i + gi}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
body = (
|
||
|
|
<div className="flex flex-col">
|
||
|
|
{displayItems.map((obj, i) => (
|
||
|
|
<ListItem
|
||
|
|
key={obj.id}
|
||
|
|
object={obj}
|
||
|
|
onSelect={toggleSelect}
|
||
|
|
selected={selected.has(obj.id)}
|
||
|
|
rowIndex={i}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<TooltipProvider delayDuration={300}>
|
||
|
|
<div className="flex h-full min-h-0 flex-col rounded-md border border-border bg-card text-card-foreground shadow-sm">
|
||
|
|
{headerRow}
|
||
|
|
<Separator />
|
||
|
|
<ScrollArea className="min-h-0 flex-1">
|
||
|
|
<div className="pb-2">{body}</div>
|
||
|
|
</ScrollArea>
|
||
|
|
</div>
|
||
|
|
</TooltipProvider>
|
||
|
|
);
|
||
|
|
}
|