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
871 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|