Blog Example
Pattern: manual TableView + formSpec + MarkdownViewSpec — full three-view setup
The blog example is the most complete example in the repo. It uses all three view types and demonstrates the key server-driven advantage: status enum values live exclusively on the server.
TIP
The demo above shows the posts table live — try adding a new post or filtering by status. All data is mocked in the browser with MSW.
Run it locally:
just example js blogWhat it demonstrates
- Separate
TableViewandformSpecendpoints (not usingWorkflowBundle) - A
rowActionlinking from the table to the markdown render view fieldOverridefor markdown textarea, custom slug validation, and max-length- Server-driven enums:
statusoptions come fromUpdatePostSchemaon the server - The three-endpoint pattern: list table, edit form, markdown render
Schemas
const PostSchema = z.object({
id: z.number(),
title: z.string(),
slug: z.string(),
body: z.string(),
status: z.enum(['draft', 'published', 'archived']),
tags: z.string().optional(),
author: z.string(), // server-controlled
updatedAt: z.string(), // server-controlled
});
// author and updatedAt excluded → read-only on the edit form
const UpdatePostSchema = z.object({
title: z.string(),
slug: z.string(),
body: z.string(),
status: z.enum(['draft', 'published', 'archived']),
tags: z.string().optional(),
});Three spec endpoints
// 1. Table view — sortable title, filterable status, Preview row action
app.get('/api/ui/posts', (_req, res) => {
res.json(
retrofit(
TableView.schema(PostSchema)
.columnOverride('title', { sortable: true })
.columnOverride('status', { filterable: true })
.rowAction({ label: 'Preview', routePattern: '/{id}/render' })
.find({ method: 'GET', url: '/posts/{id}' })
.list({ method: 'GET', url: '/posts' })
.create({ method: 'POST', url: '/posts' })
.build(),
),
);
});
// 2. Form view — markdown body, slug validation, handles both /new and /:id
app.get('/api/ui/posts/:id', (_req, res) => {
res.json(
retrofit(
formSpec(PostSchema, UpdatePostSchema)
.fieldOverride('body', { type: 'markdown' })
.fieldOverride('slug', {
helpText: 'lowercase, hyphens only',
validation: { pattern: '^[a-z0-9-]+$' },
})
.fieldOverride('tags', { helpText: 'comma-separated' })
.fieldOverride('title', { validation: { max: 200 } })
.find({ method: 'GET', url: '/posts/{id}' })
.create({ method: 'POST', url: '/posts' })
.update({ method: 'PUT', url: '/posts/{id}' })
.delete({ method: 'DELETE', url: '/posts/{id}' })
.build(),
),
);
});
// 3. Markdown render spec
app.get('/api/ui/posts/:id/render', (_req, res) => {
res.json(
retrofit({
entityEndpoint: { method: 'GET', url: '/posts/{id}' },
field: 'body',
metadata: { title: 'Preview' },
} satisfies MarkdownViewSpec),
);
});The server-driven enum story
author and updatedAt are absent from UpdatePostSchema — the server sets them. Users see them on the edit form as read-only, and they are hidden on the create form.
status is in UpdatePostSchema. The SPA derives the <sl-select> options directly from the Zod enum: ['draft', 'published', 'archived']. To add a new status like 'review', change the enum in UpdatePostSchema. The form updates on the next request — zero frontend work.
Why not WorkflowBundle here?
WorkflowBundle works when the table and form share the same schema and endpoints. The blog needs rowAction on the table (for the Preview button) and a separate markdown render endpoint. Using separate TableView and formSpec calls is more explicit and more flexible for these cases.