ubiquitous-invention/apps/web/components/views/table/table-view.tsx
Randall Stillwell 663bc77afe feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:

App
- New routes: ai, forms, planner, settings (templates/types), teams,
  doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
  builder/renderer/responses, types manager, objects creation dialog,
  card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
  health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in

Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
  (uses CT 102 shared services), removes host port mappings, adds
  Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
  metadata title flipped to ECHODO

Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
  credentials.*, *.key, *.crt, *.pem, ssh keys

Made-with: Cursor
2026-04-26 14:34:34 -05:00

871 lines
30 KiB
TypeScript

"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Plus,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
getDueDateValue,
getPriorityValue,
} from "@/components/views/list/list-item";
import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data";
import { useViewData } from "@/lib/hooks/use-view-data";
import { api } from "@/lib/trpc";
import { usePanelStore } from "@/lib/stores/panel-store";
import { cn } from "@/lib/utils";
import { TableCell, type TableFieldType } from "./table-cell";
type SortField =
| "title"
| "status"
| "priority"
| "dueDate"
| "assignees"
| "createdAt"
| string;
const CHECKBOX_W = 36;
const DEFAULT_COL_WIDTHS: Record<string, number> = {
select: CHECKBOX_W,
title: 280,
status: 128,
priority: 104,
assignees: 148,
dueDate: 120,
created: 132,
};
function collectCustomColumns(items: ViewObject[]) {
const map = new Map<string, { id: string; name: string; fieldType: string }>();
for (const o of items) {
for (const pv of o.propertyValues ?? []) {
const def = pv.propertyDefinition;
if (def.name === "Priority" || def.name === "Due Date") continue;
if (!map.has(def.id)) map.set(def.id, def);
}
}
return Array.from(map.values());
}
function getPropertyValue(obj: ViewObject, propId: string): unknown {
return obj.propertyValues?.find((p) => p.propertyDefinition.id === propId)?.value;
}
function mapCustomFieldType(ft: string): TableFieldType {
if (ft === "select") return "select";
if (ft === "date") return "date";
return "text";
}
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 compareIsoDate(a: string, b: string): number {
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 compareDue(a: string, b: string): number {
if (a === "—" && b === "—") return 0;
if (a === "—") return 1;
if (b === "—") return -1;
return compareIsoDate(a, b);
}
function sortItemsByField(items: ViewObject[], sort: ViewSort): ViewObject[] {
const dir = sort.direction === "asc" ? 1 : -1;
const copy = [...items];
copy.sort((a, b) => {
let cmp = 0;
if (sort.field.startsWith("prop:")) {
const pid = sort.field.slice("prop:".length);
const va = getPropertyValue(a, pid);
const vb = getPropertyValue(b, pid);
cmp = String(va ?? "").localeCompare(String(vb ?? ""));
} else {
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;
case "createdAt":
cmp = compareIsoDate(a.createdAt, b.createdAt);
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" }];
}
type RowEdit = {
title?: string;
status?: string | null;
priority?: string;
dueDate?: string;
properties?: Record<string, unknown>;
};
function mergePriority(obj: ViewObject, priority: string): ViewObject {
const pvs = [...(obj.propertyValues ?? [])];
const idx = pvs.findIndex((p) => p.propertyDefinition.name === "Priority");
const def =
idx >= 0
? pvs[idx].propertyDefinition
: { id: "priority-def", name: "Priority", fieldType: "select" };
if (idx >= 0) {
pvs[idx] = { ...pvs[idx], value: priority };
} else {
pvs.push({
propertyDefinition: def,
value: priority,
});
}
return { ...obj, propertyValues: pvs };
}
function mergeDueDate(obj: ViewObject, due: string): ViewObject {
const pvs = [...(obj.propertyValues ?? [])];
const idx = pvs.findIndex((p) => p.propertyDefinition.name === "Due Date");
const def =
idx >= 0
? pvs[idx].propertyDefinition
: { id: "due-def", name: "Due Date", fieldType: "date" };
if (idx >= 0) {
pvs[idx] = { ...pvs[idx], value: due };
} else {
pvs.push({
propertyDefinition: def,
value: due,
});
}
return { ...obj, propertyValues: pvs };
}
function mergeProperty(
obj: ViewObject,
propId: string,
val: unknown,
def?: { id: string; name: string; fieldType: string },
): ViewObject {
const pvs = [...(obj.propertyValues ?? [])];
const idx = pvs.findIndex((p) => p.propertyDefinition.id === propId);
if (idx >= 0) {
pvs[idx] = { ...pvs[idx], value: val };
} else if (def) {
pvs.push({ propertyDefinition: def, value: val });
}
return { ...obj, propertyValues: pvs };
}
function applyEdits(
obj: ViewObject,
edit: RowEdit | undefined,
customCols: { id: string; name: string; fieldType: string }[],
): ViewObject {
if (!edit) return obj;
let next = obj;
if (edit.title !== undefined) next = { ...next, title: edit.title };
if (edit.status !== undefined) next = { ...next, status: edit.status };
if (edit.priority !== undefined) next = mergePriority(next, edit.priority);
if (edit.dueDate !== undefined) next = mergeDueDate(next, edit.dueDate);
if (edit.properties) {
for (const [pid, val] of Object.entries(edit.properties)) {
const def = customCols.find((c) => c.id === pid);
next = mergeProperty(next, pid, val, def);
}
}
return next;
}
function ResizeHandle({
onResize,
className,
}: {
onResize: (delta: number) => void;
className?: string;
}) {
const start = React.useRef(0);
const onMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
start.current = e.clientX;
const onMove = (ev: MouseEvent) => {
const d = ev.clientX - start.current;
start.current = ev.clientX;
if (d !== 0) onResize(d);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
return (
<div
role="separator"
aria-orientation="vertical"
onMouseDown={onMouseDown}
className={cn(
"absolute right-0 top-0 z-10 h-full w-1.5 translate-x-1/2 cursor-col-resize select-none hover:bg-primary/30",
className,
)}
/>
);
}
function SortHeader({
label,
field,
sorts,
onSort,
width,
stickyClass,
children,
}: {
label: string;
field: SortField;
sorts: ViewSort[];
onSort: (field: SortField) => void;
width: number;
stickyClass?: string;
children?: React.ReactNode;
}) {
const active = sorts[0]?.field === field;
const dir = active ? sorts[0].direction : null;
return (
<th
scope="col"
style={{ width, minWidth: width }}
className={cn(
"sticky top-0 z-20 border-b border-r border-border/70 bg-muted/50 px-0 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur supports-[backdrop-filter]:bg-muted/40",
stickyClass,
)}
>
<button
type="button"
onClick={() => onSort(field)}
className="flex h-9 w-full items-center gap-1 px-2 text-left transition-colors hover:bg-accent/40 hover:text-foreground"
>
<span className="min-w-0 flex-1 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-35" />
)}
</button>
{children}
</th>
);
}
function TableSkeleton({ colCount }: { colCount: number }) {
return (
<tbody>
{Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className={cn("border-b border-border/50", i % 2 === 1 && "bg-muted/[0.12]")}>
{Array.from({ length: colCount }).map((__, j) => (
<td key={j} className="border-r border-border/50 px-2 py-1.5">
<div className="h-3 animate-pulse rounded bg-muted/80" />
</td>
))}
</tr>
))}
</tbody>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-16 text-center">
<p className="text-sm font-medium text-foreground">No rows yet</p>
<p className="max-w-sm text-xs text-muted-foreground">
Add a row or adjust filters to see data here.
</p>
</div>
);
}
export interface TableViewProps {
config: ViewConfig;
}
export function TableView({ config }: TableViewProps) {
const params = useParams();
const workspaceId =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined;
const openDetail = usePanelStore((s) => s.open);
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,
workspaceId,
parentId,
);
const [edits, setEdits] = React.useState<Record<string, RowEdit>>({});
const [isCreating, setIsCreating] = React.useState(false);
const [newTitle, setNewTitle] = React.useState("");
const utils = api.useUtils();
const createObject = api.objects.create.useMutation({
onSuccess: () => {
utils.objects.list.invalidate();
setNewTitle("");
setIsCreating(false);
},
});
const baseItems = items;
const customCols = React.useMemo(() => collectCustomColumns(baseItems), [baseItems]);
const displayItems = React.useMemo(
() => refineSort(baseItems, sorts),
[baseItems, sorts],
);
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const [widths, setWidths] = React.useState<Record<string, number>>(() => {
const w = { ...DEFAULT_COL_WIDTHS };
return w;
});
React.useEffect(() => {
setWidths((prev) => {
const next = { ...prev };
for (const c of customCols) {
if (next[c.id] === undefined) next[c.id] = 140;
}
return next;
});
}, [customCols]);
const setColWidth = React.useCallback((key: string, delta: number) => {
setWidths((prev) => {
const cur = prev[key] ?? 120;
const min = key === "select" ? CHECKBOX_W : 72;
const nextW = Math.max(min, cur + delta);
return { ...prev, [key]: nextW };
});
}, []);
const toggleSelect = React.useCallback((id: string) => {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
}, []);
const toggleSelectAll = React.useCallback(() => {
if (selected.size === displayItems.length && displayItems.length > 0) {
setSelected(new Set());
return;
}
setSelected(new Set(displayItems.map((o) => o.id)));
}, [displayItems, selected.size]);
const onHeaderSort = React.useCallback((field: SortField) => {
setSorts((s) => nextSort(field, s));
}, []);
const patchEdit = React.useCallback((id: string, patch: RowEdit) => {
setEdits((prev) => {
const cur = prev[id];
const merged: RowEdit = { ...cur, ...patch };
if (patch.properties || cur?.properties) {
merged.properties = {
...(cur?.properties ?? {}),
...(patch.properties ?? {}),
};
}
return { ...prev, [id]: merged };
});
}, []);
const getRow = React.useCallback(
(obj: ViewObject) => applyEdits(obj, edits[obj.id], customCols),
[edits, customCols],
);
const colCount =
1 + 1 + 1 + 1 + 1 + 1 + 1 + customCols.length;
const allSelected =
displayItems.length > 0 && selected.size === displayItems.length;
const tableMinWidth = React.useMemo(() => {
let sum = 0;
sum += widths.select ?? DEFAULT_COL_WIDTHS.select;
sum += widths.title ?? DEFAULT_COL_WIDTHS.title;
sum += widths.status ?? DEFAULT_COL_WIDTHS.status;
sum += widths.priority ?? DEFAULT_COL_WIDTHS.priority;
sum += widths.assignees ?? DEFAULT_COL_WIDTHS.assignees;
sum += widths.dueDate ?? DEFAULT_COL_WIDTHS.dueDate;
sum += widths.created ?? DEFAULT_COL_WIDTHS.created;
for (const c of customCols) {
sum += widths[c.id] ?? 140;
}
return sum;
}, [widths, customCols]);
return (
<div className="flex h-full min-h-0 flex-col rounded-md border border-border bg-card text-card-foreground shadow-sm">
<ScrollArea className="min-h-0 flex-1">
<table
className="w-full border-collapse text-sm"
style={{ minWidth: tableMinWidth }}
>
<thead>
<tr>
<th
scope="col"
style={{
width: widths.select ?? DEFAULT_COL_WIDTHS.select,
minWidth: widths.select ?? DEFAULT_COL_WIDTHS.select,
}}
className={cn(
"sticky left-0 top-0 z-40 border-b border-r border-border/70 bg-muted/50 px-0 backdrop-blur supports-[backdrop-filter]:bg-muted/40",
)}
>
<div className="relative flex h-9 items-center justify-center border-b border-border/40">
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
className={cn(
"h-3.5 w-3.5 cursor-pointer rounded border border-input bg-background",
"text-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
)}
aria-label="Select all rows"
/>
<ResizeHandle onResize={(d) => setColWidth("select", d)} />
</div>
</th>
<SortHeader
label="Title"
field="title"
sorts={sorts}
onSort={onHeaderSort}
width={widths.title ?? DEFAULT_COL_WIDTHS.title}
stickyClass="left-9 z-30 border-border/70 bg-muted/50 backdrop-blur supports-[backdrop-filter]:bg-muted/40"
>
<ResizeHandle onResize={(d) => setColWidth("title", d)} />
</SortHeader>
<SortHeader
label="Status"
field="status"
sorts={sorts}
onSort={onHeaderSort}
width={widths.status ?? DEFAULT_COL_WIDTHS.status}
>
<ResizeHandle onResize={(d) => setColWidth("status", d)} />
</SortHeader>
<SortHeader
label="Priority"
field="priority"
sorts={sorts}
onSort={onHeaderSort}
width={widths.priority ?? DEFAULT_COL_WIDTHS.priority}
>
<ResizeHandle onResize={(d) => setColWidth("priority", d)} />
</SortHeader>
<SortHeader
label="Assignees"
field="assignees"
sorts={sorts}
onSort={onHeaderSort}
width={widths.assignees ?? DEFAULT_COL_WIDTHS.assignees}
>
<ResizeHandle onResize={(d) => setColWidth("assignees", d)} />
</SortHeader>
<SortHeader
label="Due date"
field="dueDate"
sorts={sorts}
onSort={onHeaderSort}
width={widths.dueDate ?? DEFAULT_COL_WIDTHS.dueDate}
>
<ResizeHandle onResize={(d) => setColWidth("dueDate", d)} />
</SortHeader>
<SortHeader
label="Created"
field="createdAt"
sorts={sorts}
onSort={onHeaderSort}
width={widths.created ?? DEFAULT_COL_WIDTHS.created}
>
<ResizeHandle onResize={(d) => setColWidth("created", d)} />
</SortHeader>
{customCols.map((c) => (
<SortHeader
key={c.id}
label={c.name}
field={`prop:${c.id}`}
sorts={sorts}
onSort={onHeaderSort}
width={widths[c.id] ?? 140}
>
<ResizeHandle onResize={(d) => setColWidth(c.id, d)} />
</SortHeader>
))}
</tr>
</thead>
{isLoading ? (
<TableSkeleton colCount={colCount} />
) : total === 0 && !isCreating ? (
<tbody>
<tr>
<td colSpan={colCount} className="p-0">
<EmptyState />
</td>
</tr>
</tbody>
) : (
<tbody>
{displayItems.map((raw, rowIndex) => {
const obj = getRow(raw);
const isSelected = selected.has(obj.id);
const zebra = rowIndex % 2 === 1;
const rowBg = isSelected
? "bg-primary/10"
: zebra
? "bg-muted/[0.12]"
: "bg-background";
return (
<tr
key={obj.id}
onDoubleClick={() => openDetail("object-detail", obj.id)}
className={cn(
"group/row border-b border-border/60 transition-colors",
rowBg,
)}
>
<td
style={{
width: widths.select ?? DEFAULT_COL_WIDTHS.select,
minWidth: widths.select ?? DEFAULT_COL_WIDTHS.select,
}}
className={cn(
"sticky left-0 z-20 border-r border-border/60 px-0",
rowBg,
isSelected && "bg-primary/10",
!isSelected && "group-hover/row:bg-accent/35",
)}
>
<div className="flex h-9 items-center justify-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(obj.id)}
className={cn(
"h-3.5 w-3.5 cursor-pointer rounded border border-input bg-background",
"text-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
)}
aria-label={`Select ${obj.title}`}
/>
</div>
</td>
<td
style={{
width: widths.title ?? DEFAULT_COL_WIDTHS.title,
minWidth: widths.title ?? DEFAULT_COL_WIDTHS.title,
}}
className={cn(
"sticky left-9 z-10 border-r border-border/60 p-0",
rowBg,
isSelected && "bg-primary/10",
!isSelected && "group-hover/row:bg-accent/35",
)}
>
<TableCell
fieldType="title"
value={obj.title}
onChange={(v) =>
patchEdit(obj.id, { title: typeof v === "string" ? v : String(v) })
}
className="rounded-none border-0"
/>
</td>
<td
style={{
width: widths.status ?? DEFAULT_COL_WIDTHS.status,
minWidth: widths.status ?? DEFAULT_COL_WIDTHS.status,
}}
className={cn(
"border-r border-border/60 p-0",
rowBg,
!isSelected && "group-hover/row:bg-accent/35",
)}
>
<TableCell
fieldType="status"
value={obj.status}
onChange={(v) =>
patchEdit(obj.id, { status: typeof v === "string" ? v : null })
}
/>
</td>
<td
style={{
width: widths.priority ?? DEFAULT_COL_WIDTHS.priority,
minWidth: widths.priority ?? DEFAULT_COL_WIDTHS.priority,
}}
className={cn(
"border-r border-border/60 p-0",
rowBg,
!isSelected && "group-hover/row:bg-accent/35",
)}
>
<TableCell
fieldType="priority"
value={getPriorityValue(obj)}
onChange={(v) =>
patchEdit(obj.id, { priority: typeof v === "string" ? v : "" })
}
/>
</td>
<td
style={{
width: widths.assignees ?? DEFAULT_COL_WIDTHS.assignees,
minWidth: widths.assignees ?? DEFAULT_COL_WIDTHS.assignees,
}}
className={cn(
"border-r border-border/60 p-0",
rowBg,
!isSelected && "group-hover/row:bg-accent/35",
)}
>
<TableCell
fieldType="assignees"
value={obj.assignees ?? []}
readOnly
/>
</td>
<td
style={{
width: widths.dueDate ?? DEFAULT_COL_WIDTHS.dueDate,
minWidth: widths.dueDate ?? DEFAULT_COL_WIDTHS.dueDate,
}}
className={cn(
"border-r border-border/60 p-0",
rowBg,
!isSelected && "group-hover/row:bg-accent/35",
)}
>
<TableCell
fieldType="date"
value={getDueDateValue(obj)}
onChange={(v) =>
patchEdit(obj.id, {
dueDate: v == null || v === "" ? "" : String(v),
})
}
/>
</td>
<td
style={{
width: widths.created ?? DEFAULT_COL_WIDTHS.created,
minWidth: widths.created ?? DEFAULT_COL_WIDTHS.created,
}}
className={cn(
"border-r border-border/60 p-0",
rowBg,
!isSelected && "group-hover/row:bg-accent/35",
)}
>
<TableCell fieldType="created" value={obj.createdAt} readOnly />
</td>
{customCols.map((c) => {
const ft = mapCustomFieldType(c.fieldType);
const val = getPropertyValue(obj, c.id);
const opts = Array.from(
new Set(
baseItems
.map((o) => getPropertyValue(o, c.id))
.filter((x): x is string => typeof x === "string" && x.length > 0),
),
);
const selectOpts = opts.length > 0 ? opts : ["—"];
return (
<td
key={c.id}
style={{
width: widths[c.id] ?? 140,
minWidth: widths[c.id] ?? 140,
}}
className={cn(
"border-r border-border/60 p-0",
rowBg,
!isSelected && "group-hover/row:bg-accent/35",
)}
>
<TableCell
fieldType={ft}
value={val ?? ""}
selectOptions={selectOpts}
onChange={(v) =>
patchEdit(obj.id, {
properties: { [c.id]: v },
})
}
/>
</td>
);
})}
</tr>
);
})}
{isCreating && (
<tr
key="__inline-create"
className="border-b border-border/60 bg-muted/[0.08]"
>
<td
style={{
width: widths.select ?? DEFAULT_COL_WIDTHS.select,
minWidth: widths.select ?? DEFAULT_COL_WIDTHS.select,
}}
className="sticky left-0 z-20 border-r border-border/60 bg-muted/[0.08] px-0"
/>
<td
style={{
width: widths.title ?? DEFAULT_COL_WIDTHS.title,
minWidth: widths.title ?? DEFAULT_COL_WIDTHS.title,
}}
className="sticky left-9 z-10 border-r border-border/60 bg-muted/[0.08] p-0"
>
<div className="flex h-9 items-center px-2">
<input
autoFocus
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Task title…"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTitle.trim() && workspaceId) {
e.preventDefault();
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
parentId: parentId ?? undefined,
});
}
if (e.key === "Escape") {
setIsCreating(false);
setNewTitle("");
}
}}
onBlur={() => {
if (createObject.isPending) return;
if (newTitle.trim() && workspaceId) {
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
parentId: parentId ?? undefined,
});
} else {
setIsCreating(false);
}
}}
/>
</div>
</td>
<td
colSpan={colCount - 2}
className="border-r border-border/60 bg-muted/[0.08] p-0"
/>
</tr>
)}
</tbody>
)}
</table>
</ScrollArea>
<div className="flex shrink-0 border-t border-border/70 bg-muted/20 px-2 py-1.5">
{!isCreating ? (
<button
type="button"
className="inline-flex h-8 items-center gap-1 rounded-md border border-input bg-background px-2.5 text-xs font-medium text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => setIsCreating(true)}
>
<Plus className="h-3.5 w-3.5" />
Add row
</button>
) : null}
</div>
</div>
);
}