Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ tmp
next-env.d.ts
gomyadmin-demo
.gocache/

# Playwright MCP session artifacts
.playwright-mcp/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions deploy/cloudflare/worker/src/demo-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }),
Expand All @@ -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"] }),
Expand Down Expand Up @@ -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 } : {})
}
}

Expand Down
128 changes: 128 additions & 0 deletions docs/superpowers/specs/2026-06-16-relation-field-rendering-design.md
Original file line number Diff line number Diff line change
@@ -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 `<select>` selector in the create/edit form, populated from the target
resource's records.
- Client-side label resolution (no Go backend change).
- Demo API emits relation metadata so the live demo exercises this.
- Playwright coverage and a roadmap tick.

Out of scope: `has_many` rendering, async/typeahead selectors, a dedicated
detail/show page, and any Go backend change.

## Design

### 1. Types — `lib/api.ts`

Extend `FieldMeta` with an optional relation descriptor matching the backend
JSON:

```ts
export type RelationMeta = {
resource: string
foreign_key?: string
display_field?: string
kind: string
}
// FieldMeta gains: relation?: RelationMeta
```

### 2. Client-side resolution — shared hook

A small module (e.g. `components/admin/relation-field.tsx`) exports:

- `useRelationOptions(relation: RelationMeta)` — fetches the target resource
list via `api.list(relation.resource, new URLSearchParams())` under
`queryKey: ["relation-options", relation.resource]`. React Query dedupes by
key, so many cells/inputs pointing at the same resource trigger one fetch.
Returns `{ options: { id: string; label: string }[], labelFor(id): string, isLoading }`.
- `relationLabel(record, relation)` — pure helper picking the display label:
`record[display_field]` if `display_field` is set and present; otherwise the
first present field among `name`, `title`, `label`, `email`; otherwise the
raw id string. Exported for direct unit reasoning and reuse.

Records are keyed by `id` (the convention already used across the template).

### 3. List grid — `data-grid.tsx` `Cell`

Add a branch: when `field.type === "relation" && field.relation`, render
`<RelationLabel relation={field.relation} value={value} />`. That component
uses `useRelationOptions` and shows `labelFor(String(value))`. While loading or
if unresolved, it shows the raw id string so the cell is never empty.

### 4. Form — `resource-form.tsx` `FieldInput`

Add a branch before the generic input: when `field.type === "relation" &&
field.relation`, render `<RelationSelect>`. It is a native `<select>`
(consistent with the existing enum select) with:

- a leading empty `"Select"` option,
- one `<option value={id}>{label}</option>` 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 `<select>` whose options include the target labels,
and selecting one submits the target id.

Mocks follow the existing `mockAdminAPI` pattern (route `**/admin/api/**`).

### 7. Roadmap

Check off "Relation field rendering in the frontend" in the README roadmap.

## Verification

- `yarn typecheck` and `yarn build` pass in `templates/frontend-next-shadcn`.
- Playwright relation specs pass.
- On the live demo, Invoices/Tickets show the customer **name** in the
`Customer` column and a customer `<select>` in the edit form.
2 changes: 2 additions & 0 deletions templates/frontend-next-shadcn/components/admin/data-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -249,6 +250,7 @@ export function DataGrid({
}

function Cell({ field, value }: { field: FieldMeta; value: unknown }) {
if (field.type === "relation" && field.relation) return <RelationLabel relation={field.relation} value={value} />
if (field.type === "status" || field.type === "enum") return <Badge value={value} />
if (field.type === "datetime" || field.type === "date") return <span>{formatDate(value)}</span>
if (field.type === "money") return <span className="font-medium">{formatMoney(value)}</span>
Expand Down
96 changes: 96 additions & 0 deletions templates/frontend-next-shadcn/components/admin/relation-field.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className="text-foreground/40">—</span>
return <span className="text-foreground/82">{labelFor(id)}</span>
}

/** 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 (
<select
className="h-9 rounded-md border border-border bg-panel px-3 text-sm"
value={selected}
onChange={(event) => {
setSelected(event.target.value)
onChange(event.target.value)
}}
>
<option value="">Select</option>
{!hasSelected && <option value={selected}>{labelFor(selected)}</option>}
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 <RelationSelect relation={field.relation} value={value} onChange={onChange} />
}
if (field.enum_values?.length) {
return (
<select className="h-9 rounded-md border border-border bg-panel px-3 text-sm" defaultValue={String(value ?? "")} onChange={(event) => onChange(event.target.value)}>
Expand Down
8 changes: 8 additions & 0 deletions templates/frontend-next-shadcn/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ export type ApiResponse<T> = {
}
}

export type RelationMeta = {
resource: string
foreign_key?: string
display_field?: string
kind: string
}

export type FieldMeta = {
name: string
label: string
Expand All @@ -18,6 +25,7 @@ export type FieldMeta = {
readonly: boolean
hidden: boolean
enum_values?: string[]
relation?: RelationMeta
}

export type ActionMeta = {
Expand Down
Loading
Loading