338 lines
9.3 KiB
TypeScript
338 lines
9.3 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import { ChevronDown } from "lucide-react";
|
||
|
|
|
||
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import {
|
||
|
|
DropdownMenu,
|
||
|
|
DropdownMenuContent,
|
||
|
|
DropdownMenuItem,
|
||
|
|
DropdownMenuTrigger,
|
||
|
|
} from "@/components/ui/dropdown-menu";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
export type TableFieldType =
|
||
|
|
| "title"
|
||
|
|
| "text"
|
||
|
|
| "status"
|
||
|
|
| "priority"
|
||
|
|
| "date"
|
||
|
|
| "select"
|
||
|
|
| "assignees"
|
||
|
|
| "created";
|
||
|
|
|
||
|
|
export interface TableCellProps {
|
||
|
|
value: unknown;
|
||
|
|
fieldType: TableFieldType;
|
||
|
|
onChange?: (value: unknown) => void;
|
||
|
|
readOnly?: boolean;
|
||
|
|
className?: string;
|
||
|
|
/** Options for status, priority, or custom select columns */
|
||
|
|
selectOptions?: string[];
|
||
|
|
/** Optional: move focus to next/previous cell (Tab / Shift+Tab) */
|
||
|
|
onNavigate?: (dir: "next" | "prev") => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatDateDisplay(raw: string): string {
|
||
|
|
if (!raw || raw === "—") return "—";
|
||
|
|
try {
|
||
|
|
const d = new Date(raw);
|
||
|
|
if (Number.isNaN(d.getTime())) return raw;
|
||
|
|
return d.toLocaleDateString(undefined, {
|
||
|
|
month: "short",
|
||
|
|
day: "numeric",
|
||
|
|
year: "numeric",
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
return raw;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatCreatedDisplay(raw: string): string {
|
||
|
|
if (!raw) return "—";
|
||
|
|
try {
|
||
|
|
const d = new Date(raw);
|
||
|
|
if (Number.isNaN(d.getTime())) return raw;
|
||
|
|
return d.toLocaleString(undefined, {
|
||
|
|
month: "short",
|
||
|
|
day: "numeric",
|
||
|
|
year: "numeric",
|
||
|
|
hour: "numeric",
|
||
|
|
minute: "2-digit",
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
return String(raw);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function statusLabel(status: string | null): string {
|
||
|
|
if (!status) return "—";
|
||
|
|
return String(status).replace(/_/g, " ");
|
||
|
|
}
|
||
|
|
|
||
|
|
function initials(name: string | null): string {
|
||
|
|
return (name ?? "?")
|
||
|
|
.split(/\s+/)
|
||
|
|
.map((p) => p[0])
|
||
|
|
.join("")
|
||
|
|
.slice(0, 2)
|
||
|
|
.toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
type AssigneeLike = { user: { id: string; name: string | null; avatarUrl: string | null } };
|
||
|
|
|
||
|
|
function isAssigneeArray(v: unknown): v is AssigneeLike[] {
|
||
|
|
return Array.isArray(v) && v.every((x) => x && typeof x === "object" && "user" in x);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function TableCell({
|
||
|
|
value,
|
||
|
|
fieldType,
|
||
|
|
onChange,
|
||
|
|
readOnly = false,
|
||
|
|
className,
|
||
|
|
selectOptions = [],
|
||
|
|
onNavigate,
|
||
|
|
}: TableCellProps) {
|
||
|
|
const [editing, setEditing] = React.useState(false);
|
||
|
|
const [draft, setDraft] = React.useState("");
|
||
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||
|
|
|
||
|
|
const stringValue = React.useMemo(() => {
|
||
|
|
if (value === null || value === undefined) return "";
|
||
|
|
if (typeof value === "string") return value;
|
||
|
|
return String(value);
|
||
|
|
}, [value]);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (!editing) return;
|
||
|
|
if (fieldType === "date") {
|
||
|
|
const raw = stringValue;
|
||
|
|
if (raw && raw !== "—") {
|
||
|
|
const d = new Date(raw);
|
||
|
|
if (!Number.isNaN(d.getTime())) {
|
||
|
|
setDraft(d.toISOString().slice(0, 10));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
setDraft("");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setDraft(stringValue);
|
||
|
|
}, [editing, fieldType, stringValue]);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (editing && fieldType !== "assignees" && fieldType !== "created") {
|
||
|
|
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
||
|
|
return () => cancelAnimationFrame(id);
|
||
|
|
}
|
||
|
|
}, [editing, fieldType]);
|
||
|
|
|
||
|
|
const commit = React.useCallback(() => {
|
||
|
|
if (!onChange) {
|
||
|
|
setEditing(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (fieldType === "date") {
|
||
|
|
onChange(draft || null);
|
||
|
|
} else if (fieldType === "title" || fieldType === "text" || fieldType === "select") {
|
||
|
|
onChange(draft);
|
||
|
|
} else if (fieldType === "status" || fieldType === "priority") {
|
||
|
|
onChange(draft);
|
||
|
|
}
|
||
|
|
setEditing(false);
|
||
|
|
}, [draft, fieldType, onChange]);
|
||
|
|
|
||
|
|
const cancel = React.useCallback(() => {
|
||
|
|
setEditing(false);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const startEdit = React.useCallback(() => {
|
||
|
|
if (readOnly || fieldType === "assignees") return;
|
||
|
|
if (fieldType === "created") return;
|
||
|
|
if (fieldType === "status" || fieldType === "priority" || fieldType === "select") return;
|
||
|
|
setEditing(true);
|
||
|
|
}, [readOnly, fieldType]);
|
||
|
|
|
||
|
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||
|
|
if (e.key === "Enter") {
|
||
|
|
e.preventDefault();
|
||
|
|
commit();
|
||
|
|
} else if (e.key === "Escape") {
|
||
|
|
e.preventDefault();
|
||
|
|
cancel();
|
||
|
|
} else if (e.key === "Tab" && onNavigate) {
|
||
|
|
e.preventDefault();
|
||
|
|
commit();
|
||
|
|
onNavigate(e.shiftKey ? "prev" : "next");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const displayNode = (() => {
|
||
|
|
if (fieldType === "assignees" && isAssigneeArray(value)) {
|
||
|
|
const list = value;
|
||
|
|
if (list.length === 0) {
|
||
|
|
return <span className="text-muted-foreground">—</span>;
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<div className="flex min-w-0 -space-x-1.5 overflow-hidden">
|
||
|
|
{list.slice(0, 4).map((a) => (
|
||
|
|
<Avatar
|
||
|
|
key={a.user.id}
|
||
|
|
className="h-6 w-6 border-2 border-background text-[10px]"
|
||
|
|
>
|
||
|
|
<AvatarImage src={a.user.avatarUrl ?? undefined} alt="" />
|
||
|
|
<AvatarFallback className="text-[9px]">{initials(a.user.name)}</AvatarFallback>
|
||
|
|
</Avatar>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fieldType === "status") {
|
||
|
|
const s = typeof value === "string" ? value : value === null ? null : String(value);
|
||
|
|
return (
|
||
|
|
<Badge variant="muted" className="max-w-full truncate px-1.5 py-0 text-[11px] font-medium capitalize">
|
||
|
|
{statusLabel(s)}
|
||
|
|
</Badge>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fieldType === "priority") {
|
||
|
|
return (
|
||
|
|
<Badge variant="muted" className="max-w-full truncate px-1.5 py-0 text-[11px] font-medium">
|
||
|
|
{stringValue || "—"}
|
||
|
|
</Badge>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fieldType === "date") {
|
||
|
|
return (
|
||
|
|
<span className="tabular-nums text-muted-foreground">{formatDateDisplay(stringValue || "—")}</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fieldType === "created") {
|
||
|
|
return (
|
||
|
|
<span className="tabular-nums text-muted-foreground">
|
||
|
|
{formatCreatedDisplay(stringValue)}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<span className="min-w-0 truncate text-sm text-foreground">{stringValue || "—"}</span>
|
||
|
|
);
|
||
|
|
})();
|
||
|
|
|
||
|
|
if (readOnly || fieldType === "assignees" || fieldType === "created") {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex h-9 min-h-9 max-h-9 items-center px-2 text-sm",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{displayNode}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fieldType === "status" || fieldType === "priority" || fieldType === "select") {
|
||
|
|
const opts =
|
||
|
|
fieldType === "status"
|
||
|
|
? ["open", "in_progress", "done", "closed"]
|
||
|
|
: fieldType === "priority"
|
||
|
|
? ["High", "Medium", "Low"]
|
||
|
|
: selectOptions;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn("flex h-9 min-h-9 max-h-9 items-stretch px-1", className)}>
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="flex h-9 min-h-9 w-full min-w-0 items-center justify-between gap-1 rounded-sm px-2 text-left text-sm outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring"
|
||
|
|
>
|
||
|
|
<span className="min-w-0 flex-1">{displayNode}</span>
|
||
|
|
<ChevronDown className="h-3 w-3 shrink-0 opacity-40" aria-hidden />
|
||
|
|
</button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent className="min-w-[8rem]" align="start">
|
||
|
|
{opts.map((opt) => (
|
||
|
|
<DropdownMenuItem
|
||
|
|
key={opt}
|
||
|
|
className="text-xs"
|
||
|
|
onSelect={() => onChange?.(opt)}
|
||
|
|
>
|
||
|
|
{fieldType === "status" ? statusLabel(opt) : opt}
|
||
|
|
</DropdownMenuItem>
|
||
|
|
))}
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fieldType === "date") {
|
||
|
|
if (editing) {
|
||
|
|
return (
|
||
|
|
<div className={cn("flex h-9 items-center px-1", className)}>
|
||
|
|
<Input
|
||
|
|
ref={inputRef}
|
||
|
|
type="date"
|
||
|
|
value={draft}
|
||
|
|
onChange={(e) => setDraft(e.target.value)}
|
||
|
|
onBlur={commit}
|
||
|
|
onKeyDown={onKeyDown}
|
||
|
|
className="h-7 border-0 bg-transparent px-2 py-0 text-xs shadow-none focus-visible:ring-1"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={startEdit}
|
||
|
|
className={cn(
|
||
|
|
"flex h-9 w-full min-w-0 items-center rounded-sm px-2 text-left outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{displayNode}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (editing) {
|
||
|
|
return (
|
||
|
|
<div className={cn("flex h-9 items-center px-1", className)}>
|
||
|
|
<Input
|
||
|
|
ref={inputRef}
|
||
|
|
value={draft}
|
||
|
|
onChange={(e) => setDraft(e.target.value)}
|
||
|
|
onBlur={commit}
|
||
|
|
onKeyDown={onKeyDown}
|
||
|
|
className="h-7 border-0 bg-transparent px-2 py-0 text-sm shadow-none focus-visible:ring-1"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={startEdit}
|
||
|
|
className={cn(
|
||
|
|
"flex h-9 w-full min-w-0 items-center rounded-sm px-2 text-left outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{displayNode}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|