# 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.kt` - `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt` - `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt` - `platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml` - `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionControllerTest.kt` - `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteControllerTest.kt` ### New files (Frontend) - `web/src/components/MetadataFormRenderer.tsx` - `web/src/components/form-widgets/vibeErpTheme.tsx` - `web/src/components/form-widgets/PartnerPicker.tsx` - `web/src/components/form-widgets/ItemPicker.tsx` - `web/src/components/form-widgets/UomSelector.tsx` - `web/src/components/form-widgets/LocationPicker.tsx` - `web/src/components/form-widgets/MoneyInput.tsx` - `web/src/components/form-widgets/QuantityInput.tsx` - `web/src/components/form-widgets/index.ts` - `web/src/pages/FormDesignerPage.tsx` - `web/src/pages/ListViewDesignerPage.tsx` - `web/src/pages/MetadataAdminPage.tsx` ### Modified files - `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt` - `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt` - `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt` - `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt` - `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt` - `reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml` - `web/src/App.tsx` - `web/src/api/client.ts` - `web/src/types/api.ts` - `web/src/i18n/messages.ts` - `web/src/layout/AppLayout.tsx` - `web/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.kt` - [ ] **Step 1: Add FormYaml and ListViewYaml to MetadataYaml.kt** Add these data classes after the existing `CustomFieldTypeYaml`: ```kotlin @JsonIgnoreProperties(ignoreUnknown = true) data class FormYaml( val slug: String = "", val entityName: String = "", val title: String = "", val purpose: String = "edit", val jsonSchema: Map = emptyMap(), val uiSchema: Map = emptyMap(), val version: Int = 1, ) @JsonIgnoreProperties(ignoreUnknown = true) data class ListViewYaml( val slug: String = "", val entityName: String = "", val title: String = "", val columns: List = emptyList(), val defaultSort: ListViewSortYaml? = null, val filters: List = 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`: ```kotlin data class MetadataYamlFile( val entities: List = emptyList(), val permissions: List = emptyList(), val menus: List = emptyList(), val customFields: List = emptyList(), val forms: List = emptyList(), val listViews: List = emptyList(), ) ``` - [ ] **Step 2: Write tests for YAML parsing of forms and list views** Add to `MetadataYamlParseTest.kt`: ```kotlin @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`: 1. Add to `wipeBySource()`: ```kotlin jdbc.update("DELETE FROM metadata__form WHERE source = :source", params) jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params) ``` 2. Add `insertForms()` and `insertListViews()` private methods following the same pattern as `insertEntities()`: ```kotlin private fun insertForms(forms: List, 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`. 3. Call them from `doLoad()` after the existing insert calls: ```kotlin insertForms(merged.forms, source, now) insertListViews(merged.listViews, source, now) ``` 4. Add `formCount` and `listViewCount` to `LoadResult`. - [ ] **Step 5: Add loader tests for forms and list views** Add to `MetadataLoaderTest.kt`: ```kotlin @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` ```bash 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.kt` - [ ] **Step 1: Add GET endpoints to MetadataController** Add to the `all()` response map: ```kotlin "forms" to readPayloads("metadata__form"), "listViews" to readPayloads("metadata__list_view"), ``` Add new GET endpoints: ```kotlin @GetMapping("/forms") fun forms(): List> = readPayloads("metadata__form") @GetMapping("/forms/{slug}") fun formBySlug(@PathVariable slug: String): Map { return readPayloadBySlug("metadata__form", slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found") } @GetMapping("/list-views") fun listViews(): List> = readPayloads("metadata__list_view") @GetMapping("/list-views/{slug}") fun listViewBySlug(@PathVariable slug: String): Map { return readPayloadBySlug("metadata__list_view", slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found") } ``` Add private helper: ```kotlin private fun readPayloadBySlug(table: String, slug: String): Map? { 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 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`: ```kotlin 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): Map { 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`: ```kotlin 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): Map { 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): Map { 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`: ```yaml 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** ```bash 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.yml` - [ ] **Step 1: Add forms section to printing-shop metadata YAML** Append to `printing-shop.yml`: ```yaml 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** ```bash 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.tsx` - [ ] **Step 1: Install @rjsf packages** ```bash 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`: ```typescript // ─── Metadata Definitions ─────────────────────────────────────────── export type FormPurpose = 'create' | 'edit' | 'user-task' | 'view' export interface FormDefinition { entityName: string slug: string title: string purpose: FormPurpose jsonSchema: Record uiSchema: Record 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 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`: ```typescript export const metadata = { entities: () => apiFetch('/api/v1/_meta/metadata/entities'), permissions: () => apiFetch('/api/v1/_meta/metadata/permissions'), menus: () => apiFetch('/api/v1/_meta/metadata/menus'), customFields: () => apiFetch('/api/v1/_meta/metadata/custom-fields'), customFieldsFor: (entity: string) => apiFetch(`/api/v1/_meta/metadata/custom-fields/${entity}`), listForms: () => apiFetch('/api/v1/_meta/metadata/forms'), getForm: (slug: string) => apiFetch(`/api/v1/_meta/metadata/forms/${slug}`), saveForm: (slug: string, body: Omit) => apiFetch(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'PUT', body: JSON.stringify(body) }), deleteForm: (slug: string) => apiFetch(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'DELETE' }, false), listListViews: () => apiFetch('/api/v1/_meta/metadata/list-views'), getListView: (slug: string) => apiFetch(`/api/v1/_meta/metadata/list-views/${slug}`), saveListView: (slug: string, body: Omit) => apiFetch(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'PUT', body: JSON.stringify(body) }), deleteListView: (slug: string) => apiFetch(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'DELETE' }, false), createCustomField: (body: Omit) => apiFetch('/api/v1/_meta/metadata/custom-fields', { method: 'POST', body: JSON.stringify(body) }), updateCustomField: (key: string, body: Omit) => apiFetch(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }), deleteCustomField: (key: string) => apiFetch(`/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`: ```typescript // 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?', ``` ```typescript // 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** ```bash 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.ts` - [ ] **Step 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: 1. Fetches data from the API on mount using the existing `client.ts` functions 2. Renders a `