Metadata-Driven Forms & List Views — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship P3.2 (form renderer), P3.3 (form designer), P3.6 (list view designer), and R3 (metadata admin UIs) so Tier 1 key users can manage custom fields, design forms, and configure list views through the browser.
Architecture: Hybrid approach — core entity forms stay handcrafted; a new MetadataFormRenderer (powered by @rjsf/core) renders user-task forms and future custom entity forms. A structured property editor lets key users design form layouts. A list view designer lets them configure columns/filters/sort. A tabbed metadata admin page ties everything together. Backend adds CRUD endpoints for forms, list views, and custom fields with source='user' write protection.
Tech Stack: Kotlin/Spring Boot (backend CRUD), @rjsf/core + @rjsf/validator-ajv8 (form rendering), React + TypeScript + Tailwind (SPA pages)
Spec: docs/superpowers/specs/2026-04-10-metadata-forms-listviews-design.md
File Map
New files (Backend)
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.ktplatform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.ktplatform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.ktplatform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.ymlplatform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionControllerTest.ktplatform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteControllerTest.kt
New files (Frontend)
web/src/components/MetadataFormRenderer.tsxweb/src/components/form-widgets/vibeErpTheme.tsxweb/src/components/form-widgets/PartnerPicker.tsxweb/src/components/form-widgets/ItemPicker.tsxweb/src/components/form-widgets/UomSelector.tsxweb/src/components/form-widgets/LocationPicker.tsxweb/src/components/form-widgets/MoneyInput.tsxweb/src/components/form-widgets/QuantityInput.tsxweb/src/components/form-widgets/index.tsweb/src/pages/FormDesignerPage.tsxweb/src/pages/ListViewDesignerPage.tsxweb/src/pages/MetadataAdminPage.tsx
Modified files
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.ktplatform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.ktplatform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.ktplatform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.ktplatform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.ktreference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.ymlweb/src/App.tsxweb/src/api/client.tsweb/src/types/api.tsweb/src/i18n/messages.tsweb/src/layout/AppLayout.tsxweb/package.json
Task 1: Extend MetadataYaml + MetadataLoader for forms and list views
Files:
- Modify:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt - Modify:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt - Modify:
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt Modify:
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.ktStep 1: Add FormYaml and ListViewYaml to MetadataYaml.kt
Add these data classes after the existing CustomFieldTypeYaml:
@JsonIgnoreProperties(ignoreUnknown = true)
data class FormYaml(
val slug: String = "",
val entityName: String = "",
val title: String = "",
val purpose: String = "edit",
val jsonSchema: Map<String, Any?> = emptyMap(),
val uiSchema: Map<String, Any?> = emptyMap(),
val version: Int = 1,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ListViewYaml(
val slug: String = "",
val entityName: String = "",
val title: String = "",
val columns: List<ListViewColumnYaml> = emptyList(),
val defaultSort: ListViewSortYaml? = null,
val filters: List<ListViewFilterYaml> = emptyList(),
val pageSize: Int = 25,
val version: Int = 1,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ListViewColumnYaml(
val field: String = "",
val label: String = "",
val width: String? = null,
val sortable: Boolean = true,
val format: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ListViewSortYaml(
val field: String = "",
val direction: String = "asc",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ListViewFilterYaml(
val field: String = "",
val operator: String = "eq",
val label: String = "",
)
Add forms and listViews to MetadataYamlFile:
data class MetadataYamlFile(
val entities: List<EntityYaml> = emptyList(),
val permissions: List<PermissionYaml> = emptyList(),
val menus: List<MenuYaml> = emptyList(),
val customFields: List<CustomFieldYaml> = emptyList(),
val forms: List<FormYaml> = emptyList(),
val listViews: List<ListViewYaml> = emptyList(),
)
- Step 2: Write tests for YAML parsing of forms and list views
Add to MetadataYamlParseTest.kt:
@Test
fun `forms section parses with slug, entityName, jsonSchema, uiSchema`() {
val yaml = """
forms:
- slug: approval-form
entityName: Plate
title: Plate Approval
purpose: user-task
version: 1
jsonSchema:
type: object
properties:
approved:
type: boolean
uiSchema:
"ui:order":
- approved
""".trimIndent()
val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
assertThat(parsed.forms).hasSize(1)
assertThat(parsed.forms[0].slug).isEqualTo("approval-form")
assertThat(parsed.forms[0].entityName).isEqualTo("Plate")
assertThat(parsed.forms[0].purpose).isEqualTo("user-task")
assertThat(parsed.forms[0].jsonSchema).containsKey("type")
assertThat(parsed.forms[0].uiSchema).containsKey("ui:order")
}
@Test
fun `listViews section parses with columns and filters`() {
val yaml = """
listViews:
- slug: items-default
entityName: Item
title: Items
pageSize: 50
columns:
- field: code
label: Code
sortable: true
- field: name
label: Name
format: link
defaultSort:
field: code
direction: asc
filters:
- field: itemType
operator: eq
label: Type
""".trimIndent()
val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
assertThat(parsed.listViews).hasSize(1)
assertThat(parsed.listViews[0].slug).isEqualTo("items-default")
assertThat(parsed.listViews[0].columns).hasSize(2)
assertThat(parsed.listViews[0].defaultSort?.field).isEqualTo("code")
assertThat(parsed.listViews[0].filters).hasSize(1)
assertThat(parsed.listViews[0].pageSize).isEqualTo(50)
}
Update the existing "unknown top-level keys are ignored" test: change the test YAML key from forms: to futureSection: since forms is now a recognized key.
- Step 3: Run tests to verify parsing works
Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test
Expected: All tests pass including new ones.
- Step 4: Extend MetadataLoader to process forms and list views
In MetadataLoader.kt:
-
Add to
wipeBySource():jdbc.update("DELETE FROM metadata__form WHERE source = :source", params) jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params) -
Add
insertForms()andinsertListViews()private methods following the same pattern asinsertEntities():private fun insertForms(forms: List<FormYaml>, source: String, now: Timestamp) { forms.forEach { form -> jdbc.update( """INSERT INTO metadata__form (id, source, payload, created_at, updated_at) VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)""", MapSqlParameterSource() .addValue("id", UUID.randomUUID()) .addValue("source", source) .addValue("payload", jsonMapper.writeValueAsString(form)) .addValue("now", now), ) } }
insertListViews() follows the identical pattern for metadata__list_view.
-
Call them from
doLoad()after the existing insert calls:insertForms(merged.forms, source, now) insertListViews(merged.listViews, source, now) Add
formCountandlistViewCounttoLoadResult.
- Step 5: Add loader tests for forms and list views
Add to MetadataLoaderTest.kt:
@Test
fun `loadFromPluginJar with forms section inserts into metadata__form`() {
// Create temp JAR with metadata YAML containing a forms section
// Verify INSERT is called on metadata__form table
// Verify LoadResult.formCount == 1
}
- Step 6: Run full test suite and commit
Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test
git add platform/platform-metadata/
git commit -m "feat(metadata): extend YAML schema + loader for forms and list views"
Task 2: Add form/list-view read + write endpoints
Files:
- Modify:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt - Create:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt Create:
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.ktStep 1: Add GET endpoints to MetadataController
Add to the all() response map:
"forms" to readPayloads("metadata__form"),
"listViews" to readPayloads("metadata__list_view"),
Add new GET endpoints:
@GetMapping("/forms")
fun forms(): List<Map<String, Any?>> = readPayloads("metadata__form")
@GetMapping("/forms/{slug}")
fun formBySlug(@PathVariable slug: String): Map<String, Any?> {
return readPayloadBySlug("metadata__form", slug)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found")
}
@GetMapping("/list-views")
fun listViews(): List<Map<String, Any?>> = readPayloads("metadata__list_view")
@GetMapping("/list-views/{slug}")
fun listViewBySlug(@PathVariable slug: String): Map<String, Any?> {
return readPayloadBySlug("metadata__list_view", slug)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found")
}
Add private helper:
private fun readPayloadBySlug(table: String, slug: String): Map<String, Any?>? {
val rows = jdbc.query(
"SELECT source, payload FROM $table WHERE payload->>'slug' = :slug ORDER BY source LIMIT 1",
MapSqlParameterSource("slug", slug),
) { rs, _ ->
val source = rs.getString("source")
@Suppress("UNCHECKED_CAST")
val payload = objectMapper.readValue(rs.getString("payload") ?: "{}", Map::class.java) as Map<String, Any?>
payload + mapOf("source" to source)
}
return rows.firstOrNull()
}
- Step 2: Create FormDefinitionController with PUT/DELETE
Create platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt:
package org.vibeerp.platform.metadata.web
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpStatus
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException
import org.vibeerp.platform.security.authz.RequirePermission
import java.sql.Timestamp
import java.time.Instant
import java.util.UUID
@RestController
@RequestMapping("/api/v1/_meta/metadata/forms")
class FormDefinitionController(
private val jdbc: NamedParameterJdbcTemplate,
private val objectMapper: ObjectMapper,
) {
@PutMapping("/{slug}")
@RequirePermission("admin.metadata.write")
fun upsert(@PathVariable slug: String, @RequestBody body: Map<String, Any?>): Map<String, Any?> {
val payload = body.toMutableMap().apply { put("slug", slug) }
val payloadJson = objectMapper.writeValueAsString(payload)
val now = Timestamp.from(Instant.now())
val existing = jdbc.query(
"SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug",
MapSqlParameterSource("slug", slug),
) { rs, _ -> rs.getString("id") to rs.getString("source") }
if (existing.isNotEmpty()) {
val (id, source) = existing.first()
if (source != "user") {
throw ResponseStatusException(HttpStatus.FORBIDDEN,
"Cannot modify form '$slug' (source='$source')")
}
jdbc.update(
"UPDATE metadata__form SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)",
MapSqlParameterSource().addValue("id", id).addValue("payload", payloadJson).addValue("now", now),
)
} else {
jdbc.update(
"INSERT INTO metadata__form (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)",
MapSqlParameterSource().addValue("id", UUID.randomUUID()).addValue("payload", payloadJson).addValue("now", now),
)
}
return payload + mapOf("source" to "user")
}
@DeleteMapping("/{slug}")
@RequirePermission("admin.metadata.write")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(@PathVariable slug: String) {
val existing = jdbc.query(
"SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug",
MapSqlParameterSource("slug", slug),
) { rs, _ -> rs.getString("id") to rs.getString("source") }
if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found")
val (id, source) = existing.first()
if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete form '$slug' (source='$source')")
jdbc.update("DELETE FROM metadata__form WHERE id = CAST(:id AS uuid)", MapSqlParameterSource("id", id))
}
}
- Step 3: Create ListViewDefinitionController (identical pattern)
Same structure as FormDefinitionController, operating on metadata__list_view table. Permission: admin.metadata.write.
- Step 4: Create CustomFieldWriteController
Create platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt:
package org.vibeerp.platform.metadata.web
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpStatus
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException
import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
import org.vibeerp.platform.security.authz.RequirePermission
import java.sql.Timestamp
import java.time.Instant
import java.util.UUID
@RestController
@RequestMapping("/api/v1/_meta/metadata/custom-fields")
class CustomFieldWriteController(
private val jdbc: NamedParameterJdbcTemplate,
private val objectMapper: ObjectMapper,
private val customFieldRegistry: CustomFieldRegistry,
) {
@PostMapping
@RequirePermission("admin.metadata.write")
@ResponseStatus(HttpStatus.CREATED)
fun create(@RequestBody body: Map<String, Any?>): Map<String, Any?> {
val key = body["key"]?.toString()
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "key is required")
val existing = jdbc.query(
"SELECT id FROM metadata__custom_field WHERE payload->>'key' = :key",
MapSqlParameterSource("key", key),
) { rs, _ -> rs.getString("id") }
if (existing.isNotEmpty()) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Custom field '$key' already exists")
}
val payloadJson = objectMapper.writeValueAsString(body)
val now = Timestamp.from(Instant.now())
jdbc.update(
"INSERT INTO metadata__custom_field (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)",
MapSqlParameterSource().addValue("id", UUID.randomUUID()).addValue("payload", payloadJson).addValue("now", now),
)
customFieldRegistry.refresh()
return body + mapOf("source" to "user")
}
@PutMapping("/{key}")
@RequirePermission("admin.metadata.write")
fun update(@PathVariable key: String, @RequestBody body: Map<String, Any?>): Map<String, Any?> {
val existing = jdbc.query(
"SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key",
MapSqlParameterSource("key", key),
) { rs, _ -> rs.getString("id") to rs.getString("source") }
if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found")
val (id, source) = existing.first()
if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot modify field '$key' (source='$source')")
val payload = body.toMutableMap().apply { put("key", key) }
val payloadJson = objectMapper.writeValueAsString(payload)
val now = Timestamp.from(Instant.now())
jdbc.update(
"UPDATE metadata__custom_field SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)",
MapSqlParameterSource().addValue("id", id).addValue("payload", payloadJson).addValue("now", now),
)
customFieldRegistry.refresh()
return payload + mapOf("source" to "user")
}
@DeleteMapping("/{key}")
@RequirePermission("admin.metadata.write")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(@PathVariable key: String) {
val existing = jdbc.query(
"SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key",
MapSqlParameterSource("key", key),
) { rs, _ -> rs.getString("id") to rs.getString("source") }
if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found")
val (id, source) = existing.first()
if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete field '$key' (source='$source')")
jdbc.update("DELETE FROM metadata__custom_field WHERE id = CAST(:id AS uuid)", MapSqlParameterSource("id", id))
customFieldRegistry.refresh()
}
}
- Step 5: Add admin.metadata permissions YAML
Create platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml:
permissions:
- key: admin.metadata.read
description: View metadata configuration
- key: admin.metadata.write
description: Create, edit, and delete user metadata
menus:
- path: /admin/metadata
label: Metadata Admin
icon: database-cog
section: System
order: 900
- Step 6: Write backend tests
Create FormDefinitionControllerTest.kt and CustomFieldWriteControllerTest.kt with MockK-based unit tests covering:
- PUT creates new source='user' row
- PUT updates existing source='user' row
- PUT rejects source='core' row (403)
- DELETE removes source='user' row
- DELETE rejects source='core' row (403)
- DELETE returns 404 for unknown slug
- Custom field POST creates row + calls registry.refresh()
Custom field POST rejects duplicate key (409)
Step 7: Run full test suite and commit
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test
git add platform/platform-metadata/
git commit -m "feat(metadata): form/list-view/custom-field CRUD endpoints with source enforcement"
Task 3: Add reference form definition to printing-shop plugin
Files:
Modify:
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.ymlStep 1: Add forms section to printing-shop metadata YAML
Append to printing-shop.yml:
forms:
- slug: plate-approval-task
entityName: Plate
title: Plate Approval
purpose: user-task
version: 1
jsonSchema:
type: object
required:
- approved
properties:
plateCode:
type: string
title: Plate Code
readOnly: true
approved:
type: boolean
title: Approved
reviewNotes:
type: string
title: Review Notes
maxLength: 500
uiSchema:
"ui:order":
- plateCode
- approved
- reviewNotes
reviewNotes:
"ui:widget": textarea
- Step 2: Build and verify the plugin loads
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
git add reference-customer/
git commit -m "feat(ref-plugin): add plate-approval-task form definition"
Task 4: Install @rjsf dependencies and add SPA types/routes/client
Files:
- Modify:
web/package.json - Modify:
web/src/types/api.ts - Modify:
web/src/api/client.ts - Modify:
web/src/i18n/messages.ts - Modify:
web/src/App.tsx Modify:
web/src/layout/AppLayout.tsxStep 1: Install @rjsf packages
cd web && npm install @rjsf/core@^5 @rjsf/utils@^5 @rjsf/validator-ajv8@^5 && cd ..
- Step 2: Add TypeScript types for metadata
Add to web/src/types/api.ts:
// ─── Metadata Definitions ───────────────────────────────────────────
export type FormPurpose = 'create' | 'edit' | 'user-task' | 'view'
export interface FormDefinition {
entityName: string
slug: string
title: string
purpose: FormPurpose
jsonSchema: Record<string, unknown>
uiSchema: Record<string, unknown>
version: number
source?: string
}
export interface ListViewColumnDef {
field: string
label: string
width?: string
sortable: boolean
format?: 'date' | 'money' | 'status-badge' | 'link'
}
export interface ListViewDefinition {
entityName: string
slug: string
title: string
columns: ListViewColumnDef[]
defaultSort?: { field: string; direction: 'asc' | 'desc' }
filters?: { field: string; operator: string; label: string }[]
pageSize: number
version: number
source?: string
}
export interface CustomFieldType {
kind: string
maxLength?: number
precision?: number
scale?: number
targetEntity?: string
allowedValues?: string[]
}
export interface CustomFieldDef {
key: string
targetEntity: string
type: CustomFieldType
required: boolean
pii: boolean
labelTranslations: Record<string, string>
source?: string
}
export interface MetadataEntity {
name: string
pbc: string
table: string
description?: string
source?: string
}
export interface MetadataPermission {
key: string
description: string
source?: string
}
- Step 3: Add API client functions
Add to web/src/api/client.ts:
export const metadata = {
entities: () => apiFetch<MetadataEntity[]>('/api/v1/_meta/metadata/entities'),
permissions: () => apiFetch<MetadataPermission[]>('/api/v1/_meta/metadata/permissions'),
menus: () => apiFetch<any[]>('/api/v1/_meta/metadata/menus'),
customFields: () => apiFetch<CustomFieldDef[]>('/api/v1/_meta/metadata/custom-fields'),
customFieldsFor: (entity: string) => apiFetch<CustomFieldDef[]>(`/api/v1/_meta/metadata/custom-fields/${entity}`),
listForms: () => apiFetch<FormDefinition[]>('/api/v1/_meta/metadata/forms'),
getForm: (slug: string) => apiFetch<FormDefinition>(`/api/v1/_meta/metadata/forms/${slug}`),
saveForm: (slug: string, body: Omit<FormDefinition, 'source'>) =>
apiFetch<FormDefinition>(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteForm: (slug: string) =>
apiFetch<void>(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'DELETE' }, false),
listListViews: () => apiFetch<ListViewDefinition[]>('/api/v1/_meta/metadata/list-views'),
getListView: (slug: string) => apiFetch<ListViewDefinition>(`/api/v1/_meta/metadata/list-views/${slug}`),
saveListView: (slug: string, body: Omit<ListViewDefinition, 'source'>) =>
apiFetch<ListViewDefinition>(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteListView: (slug: string) =>
apiFetch<void>(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'DELETE' }, false),
createCustomField: (body: Omit<CustomFieldDef, 'source'>) =>
apiFetch<CustomFieldDef>('/api/v1/_meta/metadata/custom-fields', { method: 'POST', body: JSON.stringify(body) }),
updateCustomField: (key: string, body: Omit<CustomFieldDef, 'source'>) =>
apiFetch<CustomFieldDef>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteCustomField: (key: string) =>
apiFetch<void>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'DELETE' }, false),
}
- Step 4: Add i18n keys
Add to both en and zhCN objects in messages.ts:
// en additions
'nav.metadataAdmin': 'Metadata',
'page.metadataAdmin.title': 'Metadata Admin',
'tab.entities': 'Entities',
'tab.customFields': 'Custom Fields',
'tab.permissions': 'Permissions',
'tab.menus': 'Menus',
'tab.forms': 'Forms',
'tab.listViews': 'List Views',
'page.formDesigner.title': 'Form Designer',
'page.listViewDesigner.title': 'List View Designer',
'action.addField': 'Add Field',
'action.addSection': 'Add Section',
'action.discard': 'Discard',
'action.delete': 'Delete',
'label.slug': 'Slug',
'label.entity': 'Entity',
'label.purpose': 'Purpose',
'label.preview': 'Preview',
'label.source': 'Source',
'label.columns': 'Columns',
'label.filters': 'Filters',
'label.sorting': 'Sorting',
'label.pageSize': 'Page Size',
'label.fieldKey': 'Field Key',
'label.targetEntity': 'Target Entity',
'label.fieldType': 'Field Type',
'action.newCustomField': 'New Custom Field',
'confirm.delete': 'Are you sure?',
// zhCN additions
'nav.metadataAdmin': '元数据',
'page.metadataAdmin.title': '元数据管理',
'tab.entities': '实体',
'tab.customFields': '自定义字段',
'tab.permissions': '权限',
'tab.menus': '菜单',
'tab.forms': '表单',
'tab.listViews': '列表视图',
'page.formDesigner.title': '表单设计器',
'page.listViewDesigner.title': '列表视图设计器',
'action.addField': '添加字段',
'action.addSection': '添加分区',
'action.discard': '放弃',
'action.delete': '删除',
'label.slug': '标识',
'label.entity': '实体',
'label.purpose': '用途',
'label.preview': '预览',
'label.source': '来源',
'label.columns': '列',
'label.filters': '筛选',
'label.sorting': '排序',
'label.pageSize': '每页行数',
'label.fieldKey': '字段键',
'label.targetEntity': '目标实体',
'label.fieldType': '字段类型',
'action.newCustomField': '新建自定义字段',
'confirm.delete': '确定删除?',
- Step 5: Add routes and nav entry
Add to App.tsx imports and routes. Add "Metadata" nav entry to AppLayout.tsx in the System section.
- Step 6: Commit
git add web/
git commit -m "feat(web): @rjsf deps + metadata API client + types + i18n + routes"
Task 5: Build MetadataFormRenderer + VibeErp theme + custom widgets
Files:
- Create:
web/src/components/form-widgets/vibeErpTheme.tsx - Create:
web/src/components/MetadataFormRenderer.tsx - Create:
web/src/components/form-widgets/PartnerPicker.tsx - Create:
web/src/components/form-widgets/ItemPicker.tsx - Create:
web/src/components/form-widgets/UomSelector.tsx - Create:
web/src/components/form-widgets/LocationPicker.tsx - Create:
web/src/components/form-widgets/MoneyInput.tsx - Create:
web/src/components/form-widgets/QuantityInput.tsx Create:
web/src/components/form-widgets/index.tsStep 1: Create the VibeErp theme
Create vibeErpTheme.tsx that exports custom @rjsf templates wrapping fields with existing Tailwind classes:
- Labels:
"block text-sm font-medium text-slate-700" - Inputs:
"mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" - Layout:
"grid grid-cols-1 gap-4 sm:grid-cols-2" - Submit:
"btn-primary" Errors: red text under invalid fields
Step 2: Create the widget registry and picker widgets
Each picker widget (PartnerPicker, ItemPicker, UomSelector, LocationPicker) follows the same pattern:
- Fetches data from the API on mount using the existing
client.tsfunctions - Renders a
<select>dropdown with Tailwind styling - Calls
props.onChange(selectedValue)from @rjsfWidgetProps
MoneyInput and QuantityInput are number inputs with step="0.01".
Export all widgets from index.ts as a vibeWidgets: RegistryWidgetsType map.
- Step 3: Create MetadataFormRenderer
The component:
- Fetches form definition from
GET /api/v1/_meta/metadata/forms/{slug} - Renders
<Form>from@rjsf/corewith the fetched schema, theme, and widgets - Handles loading state, error state, and read-only mode
- Evaluates
ui:visibleconditions in the uiSchema for conditional field visibility
- Step 4: Verify build compiles
cd web && npm run build && cd ..
git add web/src/components/
git commit -m "feat(web): MetadataFormRenderer + VibeErp theme + 6 custom widgets"
Task 6: Build FormDesigner page
Files:
Create:
web/src/pages/FormDesignerPage.tsxStep 1: Build the structured property editor
Two-panel layout:
- Left panel: Field list as expandable rows. Each row shows key, label, type, required. Click to expand property panel (label translations, placeholder, help text, validation, visibility condition, widget override). Up/down buttons for reordering. "Add field" and "Add section divider" buttons.
-
Right panel: Live preview using
<MetadataFormRenderer>that re-renders as the field list changes. - Top bar: Title input, entity selector, purpose selector, Save/Discard buttons.
State managed as DesignerField[] → converted to JSON Schema + UI Schema via a pure function buildFormDefinition().
Save calls PUT /api/v1/_meta/metadata/forms/{slug}.
- Step 2: Verify and commit
cd web && npm run build && cd ..
git add web/src/pages/FormDesignerPage.tsx
git commit -m "feat(web): form designer — structured property editor with live preview"
Task 7: Build ListViewDesigner page
Files:
Create:
web/src/pages/ListViewDesignerPage.tsxStep 1: Build the list view configuration editor
Sections:
- Columns: Table of available fields with show/hide checkboxes, label editing, format selector, sortable toggle, up/down reorder.
- Sorting: Default sort column + direction.
- Filters: Add filterable fields with operator selector.
- Page size: Number input.
- Preview: Mock DataTable with the current column configuration.
Save calls PUT /api/v1/_meta/metadata/list-views/{slug}.
- Step 2: Verify and commit
cd web && npm run build && cd ..
git add web/src/pages/ListViewDesignerPage.tsx
git commit -m "feat(web): list view designer — column/filter/sort configuration"
Task 8: Build MetadataAdmin page
Files:
Create:
web/src/pages/MetadataAdminPage.tsxStep 1: Build the tabbed admin page
Six tabs using a simple tab bar component:
- Entities — read-only DataTable with source badge
- Custom Fields — DataTable + "New Custom Field" button + inline create form + edit/delete for source='user' rows
- Permissions — read-only DataTable with source badge
- Menus — read-only DataTable with source badge
- Forms — DataTable + "New Form" button (navigates to designer) + delete for source='user'
- List Views — DataTable + "New List View" button (navigates to designer) + delete for source='user'
Source badge: small colored pill (core = blue, plugin:* = amber, user = emerald).
Custom field inline editor: target entity dropdown, key input, type kind dropdown, required/PII checkboxes, label translation inputs (en + zh-CN).
- Step 2: Verify and commit
cd web && npm run build && cd ..
git add web/src/pages/MetadataAdminPage.tsx
git commit -m "feat(web): metadata admin — tabbed CRUD for entities, fields, forms, list views"
Task 9: Full build + smoke test + version bump
Files:
- Modify:
gradle.properties(version bump) Modify:
PROGRESS.md(update status)Step 1: Run full Gradle build
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
All tests must pass.
- Step 2: Boot and smoke test
docker compose up -d db
JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :distribution:bootRun
Smoke test checklist:
- Log in as admin
- Navigate to Metadata Admin (
/admin/metadata) - Verify all 6 tabs load with data (entities, custom fields, permissions, menus, forms, list views)
- Verify source badges show correctly (core = blue, plugin:printing-shop = amber)
- Create a custom field (source='user') through the Custom Fields tab
- Verify the new field appears in DynamicExtFields on the relevant entity's create page
- Delete the custom field, verify it disappears
- Navigate to Forms tab, verify
plate-approval-taskform shows (from plugin) - Create a new user form via the form designer
- Verify live preview renders correctly
- Save and verify it appears in the Forms tab with source='user'
- Step 3: Bump version and update PROGRESS.md
Bump vibeerp.version in gradle.properties to 0.33.0-SNAPSHOT.
Update PROGRESS.md: mark P3.2, P3.3, P3.6, R3 as done with commit refs.
- Step 4: Commit and push
git add -A
git commit -m "feat(metadata): P3.2 form renderer + P3.3 form designer + P3.6 list view designer + R3 metadata admin"
git push origin main
- Step 5: Verify CI green
gh run list --limit 1