Skip to content

Form View

The form view is driven by a FormSpec returned from GET /api/ui/{resource}/{id}. It renders a create or edit form with validation, and optionally a delete button.

Preview

Edit Post

Spec
typescript
formSpec(PostSchema, UpdatePostSchema)
  .find({ method: 'GET', url: '/posts/{id}' })
  .update({ method: 'PUT', url: '/posts/{id}' })
  .delete({ method: 'DELETE', url: '/posts/{id}' })
  .build()

Basic setup (JS)

typescript
import { formSpec } from '@retrofit-ui/builder-zod';
import { z } from 'zod';

const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  body: z.string(),
  status: z.enum(['draft', 'published', 'archived']),
});

const UpdatePostSchema = PostSchema.omit({ id: true });

// URL prefix below is arbitrary — pick anything and match `apiBase` to it.
app.get('/admin-ui/posts/:id', (req, res) => {
  res.json(
    retrofit(
      formSpec(PostSchema, UpdatePostSchema)
        .find({ method: 'GET', url: '/posts/{id}' })
        .update({ method: 'PUT', url: '/posts/{id}' })
        .delete({ method: 'DELETE', url: '/posts/{id}' })
        .build(),
    ),
  );
});

When id is "new", the SPA shows a blank create form. When it's an actual ID, the SPA fetches the entity from the find endpoint and pre-populates the fields.

Field types

retrofit-ui derives field types from Zod automatically:

Zod typeField typeInput
z.string()text<sl-input type="text">
z.string().email()email<sl-input type="email">
z.number()number<sl-input type="number">
z.boolean()checkbox<sl-checkbox>
z.enum(...)select<sl-select> with auto-derived options
z.date()date<sl-input type="date">
All field types
Spec
typescript
const schema = z.object({
  name:   z.string(),
  email:  z.string().email(),
  amount: z.number(),
  active: z.boolean(),
  status: z.enum(['draft', 'published', 'archived']),
  date:   z.date(),
});
formSpec(schema, schema).build()
// Zod types → field types automatically

Override the inferred type with fieldOverride:

typescript
formSpec(PostSchema, UpdatePostSchema)
  .fieldOverride('body', { type: 'markdown' })   // renders a textarea with "Markdown supported" hint
  .fieldOverride('notes', { type: 'textarea' })
  .fieldOverride('status', { type: 'radio-group' })  // renders a segmented button control
Override typeInput
radio-group<sl-radio-group> with <sl-radio-button> children (segmented control)
fieldOverride type examples
Markdown supported
Spec
typescript
formSpec(PostSchema, UpdatePostSchema)
  .fieldOverride('body',     { type: 'markdown' })
  .fieldOverride('notes',    { type: 'textarea' })
  .fieldOverride('priority', { type: 'radio-group' })
  .build()

Field overrides

Customise any field without re-defining the whole schema:

typescript
formSpec(PostSchema, UpdatePostSchema)
  .fieldOverride('slug', {
    helpText: 'lowercase letters and hyphens only',
    validation: { pattern: '^[a-z0-9-]+$' },
  })
  .fieldOverride('amount', {
    validation: { min: 0.01, max: 10_000 },
  })
  .fieldOverride('phone', {
    placeholder: '+1 555 000 0000',
    validation: { pattern: '^\\+?[\\d\\s\\-()]+$' },
  })
Field validation and helpers
lowercase letters and hyphens only
Must be at least 0.01
Spec
typescript
formSpec(PostSchema, UpdatePostSchema)
  .fieldOverride('slug',   { helpText: 'lowercase letters and hyphens only',
                             validation: { pattern: '^[a-z0-9-]+$' } })
  .fieldOverride('phone',  { placeholder: '+1 555 000 0000',
                             validation: { pattern: '^\\+?[\\d\\s\\-()]+$' } })
  .fieldOverride('amount', { validation: { min: 0.01, max: 10_000 } })
  .build()
Override fieldTypeEffect
typeFieldTypeOverride the auto-derived input type
labelstringOverride the auto-derived label
placeholderstringInput placeholder text
helpTextstringHelper text shown below the field
tooltipstringRenders a ? icon button next to the field label; hovering or focusing the button shows the tooltip text
requiredbooleanOverride required state
readOnlybooleanForce the field read-only
validation.minnumberMinimum value (numbers) or length (strings)
validation.maxnumberMaximum value or length
validation.patternstringRegex pattern (string inputs)

tooltip and helpText are independent and can coexist on the same field:

typescript
formSpec(PaymentSchema)
  .fieldOverride('cvv', {
    tooltip: 'The 3-digit code on the back of your card (4 digits for Amex)',
  })
  .fieldOverride('routingNumber', {
    tooltip: 'Found at the bottom-left of your check',
    helpText: '9-digit ABA number',
  })

Read-only fields

Fields in the full schema but absent from the updateSchema are rendered as read-only on the edit form. They are hidden entirely on the create form (since they don't exist yet).

This is how server-controlled fields like id, createdAt, or status work — users see them but cannot edit them.

Read-only vs editable fields
Spec
typescript
const PostSchema = z.object({
  id:        z.number(),
  title:     z.string(),
  status:    z.enum(['draft', 'published', 'archived']),
  author:    z.string(),
  updatedAt: z.date(),
});

// author and updatedAt omitted from update schema → rendered read-only
const UpdatePostSchema = PostSchema.pick({ title: true, status: true });

formSpec(PostSchema, UpdatePostSchema)
  .find({ method: 'GET', url: '/posts/{id}' })
  .update({ method: 'PUT', url: '/posts/{id}' })
  .build()

Endpoint wiring

EndpointEffect
findFetches the entity to pre-populate the form. Required for edit mode.
createWires the submit button for new entities.
updateWires the save button for existing entities.
deleteShows a "Delete" button that confirms then calls this endpoint.

Only wire the endpoints your use case needs. A read-only detail view wires only find.

Making the table row clickable

For the form view to be reachable from a table, wire find on the TableSpec too:

typescript
// Table spec — makes rows clickable
TableView.schema(PostSchema)
  .find({ method: 'GET', url: '/posts/{id}' })
  // ...
typescript
// Form spec — handles the edit page
app.get('/admin-ui/posts/:id', (req, res) => {
  res.json(retrofit(formSpec(PostSchema, UpdatePostSchema)
    .find({ method: 'GET', url: '/posts/{id}' })
    .update({ method: 'PUT', url: '/posts/{id}' })
    .delete({ method: 'DELETE', url: '/posts/{id}' })
    .build()))
});

Java

java
@GetMapping("/admin-ui/items/{id}")
public FormSpec itemForm(@PathVariable String id) {
    boolean isNew = "new".equals(id);
    var builder = FormSpec.builder()
        .field(Field.builder("name",   "Name",   "text").required(true).build())
        .field(Field.builder("active", "Active", "checkbox").build());

    if (isNew) {
        builder.create(EndpointDirective.post("/items"));
    } else {
        builder.find(EndpointDirective.get("/items/{id}"))
               .update(EndpointDirective.put("/items/{id}"))
               .delete(EndpointDirective.delete("/items/{id}"));
    }
    return builder.build();
}