From 0d7b71670ec087693374c6a6afd5b76510e76cfb Mon Sep 17 00:00:00 2001 From: Darwvin Date: Tue, 16 Jun 2026 19:01:36 +0330 Subject: [PATCH 1/2] docs: spec for frontend relation-field rendering (#3) Co-Authored-By: Claude Opus 4.8 --- ...6-06-16-relation-field-rendering-design.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-relation-field-rendering-design.md diff --git a/docs/superpowers/specs/2026-06-16-relation-field-rendering-design.md b/docs/superpowers/specs/2026-06-16-relation-field-rendering-design.md new file mode 100644 index 0000000..e23a12c --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-relation-field-rendering-design.md @@ -0,0 +1,128 @@ +# Relation Field Rendering (frontend) — Design + +Issue: #3 "Add relation field rendering in the frontend template" + +## Problem + +The Go backend already models relation fields and serializes them in +`GET /admin/api/resources` as: + +```json +{ "type": "relation", "relation": { "resource": "customers", "foreign_key": "customer_id", "display_field": "name", "kind": "belongs_to" } } +``` + +The Next.js admin template does not handle `type: "relation"`: +`FieldMeta` has no `relation` property, and neither the list grid cell nor the +form field input branch on `"relation"`. A relation field therefore renders as +its raw foreign-key id (e.g. `cus_001`) in tables and as a plain text input in +forms. + +## Scope + +In scope (this change): + +- `belongs_to` relations only. +- Readable label instead of the raw id in the resource list grid. +- A `` +(consistent with the existing enum select) with: + +- a leading empty `"Select"` option, +- one `` per resolved target record, +- `defaultValue = String(value ?? "")`, +- `onChange` calling the field's `onChange` with the selected id string. + +`editableFields` already excludes hidden/readonly/id/created_at, so relation FKs +remain editable. + +### Label fallback + +If the target list is still loading, or the id is not found, or no display +field resolves, fall back to the raw id. This keeps the UI functional even when +`display_field` is absent or the relation target is empty. + +### 5. Demo API — `deploy/cloudflare/worker/src/demo-api.mjs` + +Extend the `field()` helper to pass through an optional `relation` object, and +declare the existing relation fields with metadata: + +- `invoices.customer_id` → `relation: { resource: "customers", display_field: "name", foreign_key: "customer_id", kind: "belongs_to" }` +- `tickets.customer_id` → same. + +Records already store `customer_id` as a customer id (`cus_001`...), so the +frontend can resolve labels against the `customers` list. Redeploy the demo API +worker (through the local proxy) so the live demo reflects this. + +### 6. Tests — `tests/e2e/admin.spec.ts` + +Add a mocked resource (or extend the existing one) that includes a `relation` +field plus a target resource, and assert: + +- the list grid shows the human-readable label (target record's display field), + not the raw id; +- the edit form renders a `` in the edit form. From 1af0c0864c393f5c795f2eb863a73bbd3a6cfe04 Mon Sep 17 00:00:00 2001 From: Darwvin Date: Tue, 16 Jun 2026 19:50:57 +0330 Subject: [PATCH 2/2] feat(frontend): render belongs-to relation fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go backend already serializes relation fields (type "relation" with a relation descriptor), but the Next.js template rendered them as raw foreign-key ids in lists and a plain text input in forms. - lib/api.ts: add RelationMeta and FieldMeta.relation - components/admin/relation-field.tsx: shared client-side resolution — useRelationOptions fetches the target resource once (deduped by React Query), relationLabel picks display_field then name/title/label/email then the id; RelationLabel renders the readable label in cells, RelationSelect is a controlled native select for forms (keeps the current value selected while the async options load) - data-grid Cell + resource-form FieldInput: branch on type "relation" - demo API: field() passes through relation metadata; invoices.customer_id and tickets.customer_id declare a belongs_to relation to customers - tests/e2e/relations.spec.ts: list shows label not id; form select lists related labels and submits the related id - README: check off the relation-field roadmap item Closes #3 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + README.md | 2 +- deploy/cloudflare/worker/src/demo-api.mjs | 7 +- .../components/admin/data-grid.tsx | 2 + .../components/admin/relation-field.tsx | 96 ++++++++++++++ .../components/admin/resource-form.tsx | 4 + templates/frontend-next-shadcn/lib/api.ts | 8 ++ tests/e2e/relations.spec.ts | 121 ++++++++++++++++++ 8 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 templates/frontend-next-shadcn/components/admin/relation-field.tsx create mode 100644 tests/e2e/relations.spec.ts diff --git a/.gitignore b/.gitignore index afd5d05..5a204b2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ tmp next-env.d.ts gomyadmin-demo .gocache/ + +# Playwright MCP session artifacts +.playwright-mcp/ diff --git a/README.md b/README.md index 9da7289..819e82d 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ yarn run build - [x] Next.js admin template - [x] Drop-in HTTP handler for existing Go backends - [x] Hosted public demo -- [ ] Relation field rendering in the frontend +- [x] Relation field rendering in the frontend - [ ] Playwright e2e coverage for the CRM demo - [ ] Split heavier optional adapters into separate modules or documented opt-in packages diff --git a/deploy/cloudflare/worker/src/demo-api.mjs b/deploy/cloudflare/worker/src/demo-api.mjs index 9d512ec..96b849a 100644 --- a/deploy/cloudflare/worker/src/demo-api.mjs +++ b/deploy/cloudflare/worker/src/demo-api.mjs @@ -45,7 +45,7 @@ const resources = [ description: "Billing records, payment state, and refund actions", fields: [ field("id", "ID", "uuid", { sortable: true, readonly: true }), - field("customer_id", "Customer", "relation", { filterable: true }), + field("customer_id", "Customer", "relation", { filterable: true, relation: { resource: "customers", foreign_key: "customer_id", display_field: "name", kind: "belongs_to" } }), field("number", "Number", "string", { searchable: true, sortable: true }), field("amount", "Amount", "money", { sortable: true }), field("status", "Status", "status", { filterable: true, enum_values: ["draft", "open", "paid", "failed", "refunded"] }), @@ -65,7 +65,7 @@ const resources = [ description: "Customer support queue and escalation workflow", fields: [ field("id", "ID", "uuid", { sortable: true, readonly: true }), - field("customer_id", "Customer", "relation", { filterable: true }), + field("customer_id", "Customer", "relation", { filterable: true, relation: { resource: "customers", foreign_key: "customer_id", display_field: "name", kind: "belongs_to" } }), field("subject", "Subject", "string", { searchable: true, sortable: true }), field("priority", "Priority", "enum", { filterable: true, enum_values: ["low", "normal", "high", "urgent"] }), field("status", "Status", "status", { filterable: true, enum_values: ["open", "waiting", "solved"] }), @@ -335,7 +335,8 @@ function field(name, label, type, options = {}) { filterable: Boolean(options.filterable), readonly: Boolean(options.readonly), hidden: Boolean(options.hidden), - ...(options.enum_values ? { enum_values: options.enum_values } : {}) + ...(options.enum_values ? { enum_values: options.enum_values } : {}), + ...(options.relation ? { relation: options.relation } : {}) } } diff --git a/templates/frontend-next-shadcn/components/admin/data-grid.tsx b/templates/frontend-next-shadcn/components/admin/data-grid.tsx index 96d9b75..bff4b60 100644 --- a/templates/frontend-next-shadcn/components/admin/data-grid.tsx +++ b/templates/frontend-next-shadcn/components/admin/data-grid.tsx @@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input" import type { FieldMeta, RecordRow, ResourceMeta } from "@/lib/api" import { formatDate, formatMoney } from "@/lib/utils" +import { RelationLabel } from "@/components/admin/relation-field" export function DataGrid({ resource, @@ -249,6 +250,7 @@ export function DataGrid({ } function Cell({ field, value }: { field: FieldMeta; value: unknown }) { + if (field.type === "relation" && field.relation) return if (field.type === "status" || field.type === "enum") return if (field.type === "datetime" || field.type === "date") return {formatDate(value)} if (field.type === "money") return {formatMoney(value)} diff --git a/templates/frontend-next-shadcn/components/admin/relation-field.tsx b/templates/frontend-next-shadcn/components/admin/relation-field.tsx new file mode 100644 index 0000000..5eb9f48 --- /dev/null +++ b/templates/frontend-next-shadcn/components/admin/relation-field.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { useEffect, useState } from "react" +import { api, type RecordRow, type RelationMeta } from "@/lib/api" + +// Fields tried, in order, when a relation has no explicit display_field. +const FALLBACK_LABEL_FIELDS = ["name", "title", "label", "email"] + +/** + * Pick a human-readable label for a related record. Uses the relation's + * display_field when present, then a few common name-like fields, and finally + * falls back to the record id so the UI is never blank. + */ +export function relationLabel(record: RecordRow | undefined, relation: RelationMeta, id: string): string { + if (!record) return id + const candidates = relation.display_field ? [relation.display_field, ...FALLBACK_LABEL_FIELDS] : FALLBACK_LABEL_FIELDS + for (const key of candidates) { + const value = record[key] + if (typeof value === "string" && value.trim()) return value + if (typeof value === "number") return String(value) + } + return id +} + +type RelationOption = { id: string; label: string } + +/** + * Fetch the target resource once (deduped by React Query) and expose an + * id -> label map plus ready-to-render options for a relation field. + */ +export function useRelationOptions(relation: RelationMeta) { + const query = useQuery({ + queryKey: ["relation-options", relation.resource], + queryFn: async () => (await api.list(relation.resource, new URLSearchParams())).data ?? [] + }) + const records = query.data ?? [] + const labelFor = (id: string) => { + const match = records.find((row) => String(row.id ?? "") === id) + return relationLabel(match, relation, id) + } + const options: RelationOption[] = records.map((row) => { + const id = String(row.id ?? "") + return { id, label: relationLabel(row, relation, id) } + }) + return { options, labelFor, isLoading: query.isLoading } +} + +/** Read-only label for a belongs-to value in list/detail views. */ +export function RelationLabel({ relation, value }: { relation: RelationMeta; value: unknown }) { + const id = value == null ? "" : String(value) + const { labelFor } = useRelationOptions(relation) + if (!id) return + return {labelFor(id)} +} + +/** Native select for choosing a belongs-to value in forms. */ +export function RelationSelect({ + relation, + value, + onChange +}: { + relation: RelationMeta + value: unknown + onChange: (value: unknown) => void +}) { + const { options, labelFor } = useRelationOptions(relation) + const [selected, setSelected] = useState(value == null ? "" : String(value)) + // The record value loads asynchronously, so the form mounts this select + // before `value` is known; sync state when it arrives (and on later resets). + useEffect(() => { + setSelected(value == null ? "" : String(value)) + }, [value]) + // Controlled select: the options list also loads asynchronously, so a plain + // defaultValue would be lost before the matching option exists. Keep the + // current value's option present even while the target list is loading. + const hasSelected = selected === "" || options.some((option) => option.id === selected) + return ( + + ) +} diff --git a/templates/frontend-next-shadcn/components/admin/resource-form.tsx b/templates/frontend-next-shadcn/components/admin/resource-form.tsx index da682f5..b340af8 100644 --- a/templates/frontend-next-shadcn/components/admin/resource-form.tsx +++ b/templates/frontend-next-shadcn/components/admin/resource-form.tsx @@ -7,6 +7,7 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { api, type FieldMeta, type RecordRow, type ResourceMeta } from "@/lib/api" +import { RelationSelect } from "@/components/admin/relation-field" export function ResourceForm({ resource, id }: { resource: string; id?: string }) { const router = useRouter() @@ -71,6 +72,9 @@ export function ResourceForm({ resource, id }: { resource: string; id?: string } } function FieldInput({ field, value, onChange }: { field: FieldMeta; value: unknown; onChange: (value: unknown) => void }) { + if (field.type === "relation" && field.relation) { + return + } if (field.enum_values?.length) { return (