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
137 lines
4.3 KiB
TypeScript
137 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { ExternalLink, Loader2 } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { api } from "@/lib/trpc";
|
|
import { usePanelStore } from "@/lib/stores/panel-store";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import type { FormField } from "./form-renderer";
|
|
|
|
function formatCellValue(value: unknown): string {
|
|
if (value === undefined || value === null) return "—";
|
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
return String(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ");
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return "—";
|
|
}
|
|
}
|
|
|
|
function tableColumns(fields: FormField[]): FormField[] {
|
|
return fields.filter((f) => f.type !== "section_header" && f.type !== "divider");
|
|
}
|
|
|
|
export interface FormResponsesProps {
|
|
formId: string;
|
|
fields: FormField[];
|
|
className?: string;
|
|
}
|
|
|
|
export function FormResponses({ formId, fields, className }: FormResponsesProps) {
|
|
const open = usePanelStore((s) => s.open);
|
|
const cols = React.useMemo(() => tableColumns(fields), [fields]);
|
|
|
|
const listQuery = api.forms.listResponses.useQuery(
|
|
{ formId },
|
|
{ enabled: Boolean(formId) },
|
|
);
|
|
|
|
if (listQuery.isPending) {
|
|
return (
|
|
<div className={cn("flex justify-center py-12", className)}>
|
|
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
|
|
<span className="sr-only">Loading responses</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (listQuery.isError) {
|
|
return (
|
|
<p className={cn("text-sm text-muted-foreground", className)}>
|
|
Could not load responses.
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const rows = listQuery.data?.responses ?? [];
|
|
|
|
if (rows.length === 0) {
|
|
return (
|
|
<p className={cn("text-sm text-muted-foreground", className)}>No responses yet.</p>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("w-full overflow-x-auto rounded-md border border-border", className)}>
|
|
<table className="w-full min-w-[640px] border-collapse text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border bg-muted/40">
|
|
{cols.map((f) => (
|
|
<th
|
|
key={f.id}
|
|
className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground"
|
|
>
|
|
{f.label}
|
|
</th>
|
|
))}
|
|
<th className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground">
|
|
Submitted
|
|
</th>
|
|
<th className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground">
|
|
Task
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => {
|
|
const data =
|
|
row.data && typeof row.data === "object" && row.data !== null
|
|
? (row.data as Record<string, unknown>)
|
|
: {};
|
|
|
|
const submitted =
|
|
row.submittedAt instanceof Date
|
|
? row.submittedAt.toLocaleString()
|
|
: String(row.submittedAt ?? "—");
|
|
|
|
return (
|
|
<tr key={row.id} className="border-b border-border last:border-0">
|
|
{cols.map((f) => (
|
|
<td key={f.id} className="max-w-[240px] truncate px-3 py-2 align-top">
|
|
{formatCellValue(data[f.id])}
|
|
</td>
|
|
))}
|
|
<td className="whitespace-nowrap px-3 py-2 align-top text-muted-foreground">
|
|
{submitted}
|
|
</td>
|
|
<td className="px-3 py-2 align-top">
|
|
{row.createdObjectId ? (
|
|
<Button
|
|
type="button"
|
|
variant="link"
|
|
className="inline-flex h-auto items-center gap-1 p-0 text-primary"
|
|
onClick={() => open("object-detail", row.createdObjectId)}
|
|
>
|
|
Open task
|
|
<ExternalLink className="size-3.5 opacity-70" aria-hidden />
|
|
</Button>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|