Commit 025469cf1365588503d76019f579f7f5caa0a5ce

Authored by zichun
1 parent 39827f04

docs(plan): implementation plan for metadata forms + list views (P3.2/P3.3/P3.6/R3)

9 tasks: backend YAML/loader extension, CRUD endpoints with source
enforcement, @rjsf form renderer + custom widgets, form designer,
list view designer, metadata admin tabbed UI, smoke test + version bump.
docs/superpowers/plans/2026-04-10-metadata-forms-listviews.md 0 → 100644
  1 +# Metadata-Driven Forms & List Views — Implementation Plan
  2 +
  3 +> **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.
  4 +
  5 +**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.
  6 +
  7 +**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.
  8 +
  9 +**Tech Stack:** Kotlin/Spring Boot (backend CRUD), @rjsf/core + @rjsf/validator-ajv8 (form rendering), React + TypeScript + Tailwind (SPA pages)
  10 +
  11 +**Spec:** `docs/superpowers/specs/2026-04-10-metadata-forms-listviews-design.md`
  12 +
  13 +---
  14 +
  15 +## File Map
  16 +
  17 +### New files (Backend)
  18 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt`
  19 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt`
  20 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt`
  21 +- `platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml`
  22 +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionControllerTest.kt`
  23 +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteControllerTest.kt`
  24 +
  25 +### New files (Frontend)
  26 +- `web/src/components/MetadataFormRenderer.tsx`
  27 +- `web/src/components/form-widgets/vibeErpTheme.tsx`
  28 +- `web/src/components/form-widgets/PartnerPicker.tsx`
  29 +- `web/src/components/form-widgets/ItemPicker.tsx`
  30 +- `web/src/components/form-widgets/UomSelector.tsx`
  31 +- `web/src/components/form-widgets/LocationPicker.tsx`
  32 +- `web/src/components/form-widgets/MoneyInput.tsx`
  33 +- `web/src/components/form-widgets/QuantityInput.tsx`
  34 +- `web/src/components/form-widgets/index.ts`
  35 +- `web/src/pages/FormDesignerPage.tsx`
  36 +- `web/src/pages/ListViewDesignerPage.tsx`
  37 +- `web/src/pages/MetadataAdminPage.tsx`
  38 +
  39 +### Modified files
  40 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt`
  41 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt`
  42 +- `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt`
  43 +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt`
  44 +- `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt`
  45 +- `reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml`
  46 +- `web/src/App.tsx`
  47 +- `web/src/api/client.ts`
  48 +- `web/src/types/api.ts`
  49 +- `web/src/i18n/messages.ts`
  50 +- `web/src/layout/AppLayout.tsx`
  51 +- `web/package.json`
  52 +
  53 +---
  54 +
  55 +## Task 1: Extend MetadataYaml + MetadataLoader for forms and list views
  56 +
  57 +**Files:**
  58 +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt`
  59 +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt`
  60 +- Modify: `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt`
  61 +- Modify: `platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt`
  62 +
  63 +- [ ] **Step 1: Add FormYaml and ListViewYaml to MetadataYaml.kt**
  64 +
  65 +Add these data classes after the existing `CustomFieldTypeYaml`:
  66 +
  67 +```kotlin
  68 +@JsonIgnoreProperties(ignoreUnknown = true)
  69 +data class FormYaml(
  70 + val slug: String = "",
  71 + val entityName: String = "",
  72 + val title: String = "",
  73 + val purpose: String = "edit",
  74 + val jsonSchema: Map<String, Any?> = emptyMap(),
  75 + val uiSchema: Map<String, Any?> = emptyMap(),
  76 + val version: Int = 1,
  77 +)
  78 +
  79 +@JsonIgnoreProperties(ignoreUnknown = true)
  80 +data class ListViewYaml(
  81 + val slug: String = "",
  82 + val entityName: String = "",
  83 + val title: String = "",
  84 + val columns: List<ListViewColumnYaml> = emptyList(),
  85 + val defaultSort: ListViewSortYaml? = null,
  86 + val filters: List<ListViewFilterYaml> = emptyList(),
  87 + val pageSize: Int = 25,
  88 + val version: Int = 1,
  89 +)
  90 +
  91 +@JsonIgnoreProperties(ignoreUnknown = true)
  92 +data class ListViewColumnYaml(
  93 + val field: String = "",
  94 + val label: String = "",
  95 + val width: String? = null,
  96 + val sortable: Boolean = true,
  97 + val format: String? = null,
  98 +)
  99 +
  100 +@JsonIgnoreProperties(ignoreUnknown = true)
  101 +data class ListViewSortYaml(
  102 + val field: String = "",
  103 + val direction: String = "asc",
  104 +)
  105 +
  106 +@JsonIgnoreProperties(ignoreUnknown = true)
  107 +data class ListViewFilterYaml(
  108 + val field: String = "",
  109 + val operator: String = "eq",
  110 + val label: String = "",
  111 +)
  112 +```
  113 +
  114 +Add `forms` and `listViews` to `MetadataYamlFile`:
  115 +
  116 +```kotlin
  117 +data class MetadataYamlFile(
  118 + val entities: List<EntityYaml> = emptyList(),
  119 + val permissions: List<PermissionYaml> = emptyList(),
  120 + val menus: List<MenuYaml> = emptyList(),
  121 + val customFields: List<CustomFieldYaml> = emptyList(),
  122 + val forms: List<FormYaml> = emptyList(),
  123 + val listViews: List<ListViewYaml> = emptyList(),
  124 +)
  125 +```
  126 +
  127 +- [ ] **Step 2: Write tests for YAML parsing of forms and list views**
  128 +
  129 +Add to `MetadataYamlParseTest.kt`:
  130 +
  131 +```kotlin
  132 +@Test
  133 +fun `forms section parses with slug, entityName, jsonSchema, uiSchema`() {
  134 + val yaml = """
  135 + forms:
  136 + - slug: approval-form
  137 + entityName: Plate
  138 + title: Plate Approval
  139 + purpose: user-task
  140 + version: 1
  141 + jsonSchema:
  142 + type: object
  143 + properties:
  144 + approved:
  145 + type: boolean
  146 + uiSchema:
  147 + "ui:order":
  148 + - approved
  149 + """.trimIndent()
  150 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  151 + assertThat(parsed.forms).hasSize(1)
  152 + assertThat(parsed.forms[0].slug).isEqualTo("approval-form")
  153 + assertThat(parsed.forms[0].entityName).isEqualTo("Plate")
  154 + assertThat(parsed.forms[0].purpose).isEqualTo("user-task")
  155 + assertThat(parsed.forms[0].jsonSchema).containsKey("type")
  156 + assertThat(parsed.forms[0].uiSchema).containsKey("ui:order")
  157 +}
  158 +
  159 +@Test
  160 +fun `listViews section parses with columns and filters`() {
  161 + val yaml = """
  162 + listViews:
  163 + - slug: items-default
  164 + entityName: Item
  165 + title: Items
  166 + pageSize: 50
  167 + columns:
  168 + - field: code
  169 + label: Code
  170 + sortable: true
  171 + - field: name
  172 + label: Name
  173 + format: link
  174 + defaultSort:
  175 + field: code
  176 + direction: asc
  177 + filters:
  178 + - field: itemType
  179 + operator: eq
  180 + label: Type
  181 + """.trimIndent()
  182 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  183 + assertThat(parsed.listViews).hasSize(1)
  184 + assertThat(parsed.listViews[0].slug).isEqualTo("items-default")
  185 + assertThat(parsed.listViews[0].columns).hasSize(2)
  186 + assertThat(parsed.listViews[0].defaultSort?.field).isEqualTo("code")
  187 + assertThat(parsed.listViews[0].filters).hasSize(1)
  188 + assertThat(parsed.listViews[0].pageSize).isEqualTo(50)
  189 +}
  190 +```
  191 +
  192 +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.
  193 +
  194 +- [ ] **Step 3: Run tests to verify parsing works**
  195 +
  196 +Run: `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test`
  197 +Expected: All tests pass including new ones.
  198 +
  199 +- [ ] **Step 4: Extend MetadataLoader to process forms and list views**
  200 +
  201 +In `MetadataLoader.kt`:
  202 +
  203 +1. Add to `wipeBySource()`:
  204 +```kotlin
  205 +jdbc.update("DELETE FROM metadata__form WHERE source = :source", params)
  206 +jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params)
  207 +```
  208 +
  209 +2. Add `insertForms()` and `insertListViews()` private methods following the same pattern as `insertEntities()`:
  210 +```kotlin
  211 +private fun insertForms(forms: List<FormYaml>, source: String, now: Timestamp) {
  212 + forms.forEach { form ->
  213 + jdbc.update(
  214 + """INSERT INTO metadata__form (id, source, payload, created_at, updated_at)
  215 + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)""",
  216 + MapSqlParameterSource()
  217 + .addValue("id", UUID.randomUUID())
  218 + .addValue("source", source)
  219 + .addValue("payload", jsonMapper.writeValueAsString(form))
  220 + .addValue("now", now),
  221 + )
  222 + }
  223 +}
  224 +```
  225 +
  226 +`insertListViews()` follows the identical pattern for `metadata__list_view`.
  227 +
  228 +3. Call them from `doLoad()` after the existing insert calls:
  229 +```kotlin
  230 +insertForms(merged.forms, source, now)
  231 +insertListViews(merged.listViews, source, now)
  232 +```
  233 +
  234 +4. Add `formCount` and `listViewCount` to `LoadResult`.
  235 +
  236 +- [ ] **Step 5: Add loader tests for forms and list views**
  237 +
  238 +Add to `MetadataLoaderTest.kt`:
  239 +
  240 +```kotlin
  241 +@Test
  242 +fun `loadFromPluginJar with forms section inserts into metadata__form`() {
  243 + // Create temp JAR with metadata YAML containing a forms section
  244 + // Verify INSERT is called on metadata__form table
  245 + // Verify LoadResult.formCount == 1
  246 +}
  247 +```
  248 +
  249 +- [ ] **Step 6: Run full test suite and commit**
  250 +
  251 +Run: `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test`
  252 +
  253 +```bash
  254 +git add platform/platform-metadata/
  255 +git commit -m "feat(metadata): extend YAML schema + loader for forms and list views"
  256 +```
  257 +
  258 +---
  259 +
  260 +## Task 2: Add form/list-view read + write endpoints
  261 +
  262 +**Files:**
  263 +- Modify: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt`
  264 +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt`
  265 +- Create: `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/ListViewDefinitionController.kt`
  266 +
  267 +- [ ] **Step 1: Add GET endpoints to MetadataController**
  268 +
  269 +Add to the `all()` response map:
  270 +```kotlin
  271 +"forms" to readPayloads("metadata__form"),
  272 +"listViews" to readPayloads("metadata__list_view"),
  273 +```
  274 +
  275 +Add new GET endpoints:
  276 +```kotlin
  277 +@GetMapping("/forms")
  278 +fun forms(): List<Map<String, Any?>> = readPayloads("metadata__form")
  279 +
  280 +@GetMapping("/forms/{slug}")
  281 +fun formBySlug(@PathVariable slug: String): Map<String, Any?> {
  282 + return readPayloadBySlug("metadata__form", slug)
  283 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found")
  284 +}
  285 +
  286 +@GetMapping("/list-views")
  287 +fun listViews(): List<Map<String, Any?>> = readPayloads("metadata__list_view")
  288 +
  289 +@GetMapping("/list-views/{slug}")
  290 +fun listViewBySlug(@PathVariable slug: String): Map<String, Any?> {
  291 + return readPayloadBySlug("metadata__list_view", slug)
  292 + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "List view '$slug' not found")
  293 +}
  294 +```
  295 +
  296 +Add private helper:
  297 +```kotlin
  298 +private fun readPayloadBySlug(table: String, slug: String): Map<String, Any?>? {
  299 + val rows = jdbc.query(
  300 + "SELECT source, payload FROM $table WHERE payload->>'slug' = :slug ORDER BY source LIMIT 1",
  301 + MapSqlParameterSource("slug", slug),
  302 + ) { rs, _ ->
  303 + val source = rs.getString("source")
  304 + @Suppress("UNCHECKED_CAST")
  305 + val payload = objectMapper.readValue(rs.getString("payload") ?: "{}", Map::class.java) as Map<String, Any?>
  306 + payload + mapOf("source" to source)
  307 + }
  308 + return rows.firstOrNull()
  309 +}
  310 +```
  311 +
  312 +- [ ] **Step 2: Create FormDefinitionController with PUT/DELETE**
  313 +
  314 +Create `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/FormDefinitionController.kt`:
  315 +
  316 +```kotlin
  317 +package org.vibeerp.platform.metadata.web
  318 +
  319 +import com.fasterxml.jackson.databind.ObjectMapper
  320 +import org.springframework.http.HttpStatus
  321 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  322 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  323 +import org.springframework.web.bind.annotation.*
  324 +import org.springframework.web.server.ResponseStatusException
  325 +import org.vibeerp.platform.security.authz.RequirePermission
  326 +import java.sql.Timestamp
  327 +import java.time.Instant
  328 +import java.util.UUID
  329 +
  330 +@RestController
  331 +@RequestMapping("/api/v1/_meta/metadata/forms")
  332 +class FormDefinitionController(
  333 + private val jdbc: NamedParameterJdbcTemplate,
  334 + private val objectMapper: ObjectMapper,
  335 +) {
  336 + @PutMapping("/{slug}")
  337 + @RequirePermission("admin.metadata.write")
  338 + fun upsert(@PathVariable slug: String, @RequestBody body: Map<String, Any?>): Map<String, Any?> {
  339 + val payload = body.toMutableMap().apply { put("slug", slug) }
  340 + val payloadJson = objectMapper.writeValueAsString(payload)
  341 + val now = Timestamp.from(Instant.now())
  342 +
  343 + val existing = jdbc.query(
  344 + "SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug",
  345 + MapSqlParameterSource("slug", slug),
  346 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  347 +
  348 + if (existing.isNotEmpty()) {
  349 + val (id, source) = existing.first()
  350 + if (source != "user") {
  351 + throw ResponseStatusException(HttpStatus.FORBIDDEN,
  352 + "Cannot modify form '$slug' (source='$source')")
  353 + }
  354 + jdbc.update(
  355 + "UPDATE metadata__form SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)",
  356 + MapSqlParameterSource().addValue("id", id).addValue("payload", payloadJson).addValue("now", now),
  357 + )
  358 + } else {
  359 + jdbc.update(
  360 + "INSERT INTO metadata__form (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)",
  361 + MapSqlParameterSource().addValue("id", UUID.randomUUID()).addValue("payload", payloadJson).addValue("now", now),
  362 + )
  363 + }
  364 + return payload + mapOf("source" to "user")
  365 + }
  366 +
  367 + @DeleteMapping("/{slug}")
  368 + @RequirePermission("admin.metadata.write")
  369 + @ResponseStatus(HttpStatus.NO_CONTENT)
  370 + fun delete(@PathVariable slug: String) {
  371 + val existing = jdbc.query(
  372 + "SELECT id, source FROM metadata__form WHERE payload->>'slug' = :slug",
  373 + MapSqlParameterSource("slug", slug),
  374 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  375 +
  376 + if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Form '$slug' not found")
  377 + val (id, source) = existing.first()
  378 + if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete form '$slug' (source='$source')")
  379 + jdbc.update("DELETE FROM metadata__form WHERE id = CAST(:id AS uuid)", MapSqlParameterSource("id", id))
  380 + }
  381 +}
  382 +```
  383 +
  384 +- [ ] **Step 3: Create ListViewDefinitionController (identical pattern)**
  385 +
  386 +Same structure as `FormDefinitionController`, operating on `metadata__list_view` table. Permission: `admin.metadata.write`.
  387 +
  388 +- [ ] **Step 4: Create CustomFieldWriteController**
  389 +
  390 +Create `platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/CustomFieldWriteController.kt`:
  391 +
  392 +```kotlin
  393 +package org.vibeerp.platform.metadata.web
  394 +
  395 +import com.fasterxml.jackson.databind.ObjectMapper
  396 +import org.springframework.http.HttpStatus
  397 +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
  398 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  399 +import org.springframework.web.bind.annotation.*
  400 +import org.springframework.web.server.ResponseStatusException
  401 +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
  402 +import org.vibeerp.platform.security.authz.RequirePermission
  403 +import java.sql.Timestamp
  404 +import java.time.Instant
  405 +import java.util.UUID
  406 +
  407 +@RestController
  408 +@RequestMapping("/api/v1/_meta/metadata/custom-fields")
  409 +class CustomFieldWriteController(
  410 + private val jdbc: NamedParameterJdbcTemplate,
  411 + private val objectMapper: ObjectMapper,
  412 + private val customFieldRegistry: CustomFieldRegistry,
  413 +) {
  414 + @PostMapping
  415 + @RequirePermission("admin.metadata.write")
  416 + @ResponseStatus(HttpStatus.CREATED)
  417 + fun create(@RequestBody body: Map<String, Any?>): Map<String, Any?> {
  418 + val key = body["key"]?.toString()
  419 + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "key is required")
  420 +
  421 + val existing = jdbc.query(
  422 + "SELECT id FROM metadata__custom_field WHERE payload->>'key' = :key",
  423 + MapSqlParameterSource("key", key),
  424 + ) { rs, _ -> rs.getString("id") }
  425 + if (existing.isNotEmpty()) {
  426 + throw ResponseStatusException(HttpStatus.CONFLICT, "Custom field '$key' already exists")
  427 + }
  428 +
  429 + val payloadJson = objectMapper.writeValueAsString(body)
  430 + val now = Timestamp.from(Instant.now())
  431 + jdbc.update(
  432 + "INSERT INTO metadata__custom_field (id, source, payload, created_at, updated_at) VALUES (:id, 'user', CAST(:payload AS jsonb), :now, :now)",
  433 + MapSqlParameterSource().addValue("id", UUID.randomUUID()).addValue("payload", payloadJson).addValue("now", now),
  434 + )
  435 + customFieldRegistry.refresh()
  436 + return body + mapOf("source" to "user")
  437 + }
  438 +
  439 + @PutMapping("/{key}")
  440 + @RequirePermission("admin.metadata.write")
  441 + fun update(@PathVariable key: String, @RequestBody body: Map<String, Any?>): Map<String, Any?> {
  442 + val existing = jdbc.query(
  443 + "SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key",
  444 + MapSqlParameterSource("key", key),
  445 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  446 + if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found")
  447 + val (id, source) = existing.first()
  448 + if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot modify field '$key' (source='$source')")
  449 +
  450 + val payload = body.toMutableMap().apply { put("key", key) }
  451 + val payloadJson = objectMapper.writeValueAsString(payload)
  452 + val now = Timestamp.from(Instant.now())
  453 + jdbc.update(
  454 + "UPDATE metadata__custom_field SET payload = CAST(:payload AS jsonb), updated_at = :now WHERE id = CAST(:id AS uuid)",
  455 + MapSqlParameterSource().addValue("id", id).addValue("payload", payloadJson).addValue("now", now),
  456 + )
  457 + customFieldRegistry.refresh()
  458 + return payload + mapOf("source" to "user")
  459 + }
  460 +
  461 + @DeleteMapping("/{key}")
  462 + @RequirePermission("admin.metadata.write")
  463 + @ResponseStatus(HttpStatus.NO_CONTENT)
  464 + fun delete(@PathVariable key: String) {
  465 + val existing = jdbc.query(
  466 + "SELECT id, source FROM metadata__custom_field WHERE payload->>'key' = :key",
  467 + MapSqlParameterSource("key", key),
  468 + ) { rs, _ -> rs.getString("id") to rs.getString("source") }
  469 + if (existing.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Custom field '$key' not found")
  470 + val (id, source) = existing.first()
  471 + if (source != "user") throw ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete field '$key' (source='$source')")
  472 +
  473 + jdbc.update("DELETE FROM metadata__custom_field WHERE id = CAST(:id AS uuid)", MapSqlParameterSource("id", id))
  474 + customFieldRegistry.refresh()
  475 + }
  476 +}
  477 +```
  478 +
  479 +- [ ] **Step 5: Add admin.metadata permissions YAML**
  480 +
  481 +Create `platform/platform-metadata/src/main/resources/META-INF/vibe-erp/metadata/platform-metadata.yml`:
  482 +
  483 +```yaml
  484 +permissions:
  485 + - key: admin.metadata.read
  486 + description: View metadata configuration
  487 + - key: admin.metadata.write
  488 + description: Create, edit, and delete user metadata
  489 +
  490 +menus:
  491 + - path: /admin/metadata
  492 + label: Metadata Admin
  493 + icon: database-cog
  494 + section: System
  495 + order: 900
  496 +```
  497 +
  498 +- [ ] **Step 6: Write backend tests**
  499 +
  500 +Create `FormDefinitionControllerTest.kt` and `CustomFieldWriteControllerTest.kt` with MockK-based unit tests covering:
  501 +- PUT creates new source='user' row
  502 +- PUT updates existing source='user' row
  503 +- PUT rejects source='core' row (403)
  504 +- DELETE removes source='user' row
  505 +- DELETE rejects source='core' row (403)
  506 +- DELETE returns 404 for unknown slug
  507 +- Custom field POST creates row + calls registry.refresh()
  508 +- Custom field POST rejects duplicate key (409)
  509 +
  510 +- [ ] **Step 7: Run full test suite and commit**
  511 +
  512 +```bash
  513 +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :platform:platform-metadata:test
  514 +git add platform/platform-metadata/
  515 +git commit -m "feat(metadata): form/list-view/custom-field CRUD endpoints with source enforcement"
  516 +```
  517 +
  518 +---
  519 +
  520 +## Task 3: Add reference form definition to printing-shop plugin
  521 +
  522 +**Files:**
  523 +- Modify: `reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/metadata/printing-shop.yml`
  524 +
  525 +- [ ] **Step 1: Add forms section to printing-shop metadata YAML**
  526 +
  527 +Append to `printing-shop.yml`:
  528 +
  529 +```yaml
  530 +forms:
  531 + - slug: plate-approval-task
  532 + entityName: Plate
  533 + title: Plate Approval
  534 + purpose: user-task
  535 + version: 1
  536 + jsonSchema:
  537 + type: object
  538 + required:
  539 + - approved
  540 + properties:
  541 + plateCode:
  542 + type: string
  543 + title: Plate Code
  544 + readOnly: true
  545 + approved:
  546 + type: boolean
  547 + title: Approved
  548 + reviewNotes:
  549 + type: string
  550 + title: Review Notes
  551 + maxLength: 500
  552 + uiSchema:
  553 + "ui:order":
  554 + - plateCode
  555 + - approved
  556 + - reviewNotes
  557 + reviewNotes:
  558 + "ui:widget": textarea
  559 +```
  560 +
  561 +- [ ] **Step 2: Build and verify the plugin loads**
  562 +
  563 +```bash
  564 +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
  565 +git add reference-customer/
  566 +git commit -m "feat(ref-plugin): add plate-approval-task form definition"
  567 +```
  568 +
  569 +---
  570 +
  571 +## Task 4: Install @rjsf dependencies and add SPA types/routes/client
  572 +
  573 +**Files:**
  574 +- Modify: `web/package.json`
  575 +- Modify: `web/src/types/api.ts`
  576 +- Modify: `web/src/api/client.ts`
  577 +- Modify: `web/src/i18n/messages.ts`
  578 +- Modify: `web/src/App.tsx`
  579 +- Modify: `web/src/layout/AppLayout.tsx`
  580 +
  581 +- [ ] **Step 1: Install @rjsf packages**
  582 +
  583 +```bash
  584 +cd web && npm install @rjsf/core@^5 @rjsf/utils@^5 @rjsf/validator-ajv8@^5 && cd ..
  585 +```
  586 +
  587 +- [ ] **Step 2: Add TypeScript types for metadata**
  588 +
  589 +Add to `web/src/types/api.ts`:
  590 +
  591 +```typescript
  592 +// ─── Metadata Definitions ───────────────────────────────────────────
  593 +
  594 +export type FormPurpose = 'create' | 'edit' | 'user-task' | 'view'
  595 +
  596 +export interface FormDefinition {
  597 + entityName: string
  598 + slug: string
  599 + title: string
  600 + purpose: FormPurpose
  601 + jsonSchema: Record<string, unknown>
  602 + uiSchema: Record<string, unknown>
  603 + version: number
  604 + source?: string
  605 +}
  606 +
  607 +export interface ListViewColumnDef {
  608 + field: string
  609 + label: string
  610 + width?: string
  611 + sortable: boolean
  612 + format?: 'date' | 'money' | 'status-badge' | 'link'
  613 +}
  614 +
  615 +export interface ListViewDefinition {
  616 + entityName: string
  617 + slug: string
  618 + title: string
  619 + columns: ListViewColumnDef[]
  620 + defaultSort?: { field: string; direction: 'asc' | 'desc' }
  621 + filters?: { field: string; operator: string; label: string }[]
  622 + pageSize: number
  623 + version: number
  624 + source?: string
  625 +}
  626 +
  627 +export interface CustomFieldType {
  628 + kind: string
  629 + maxLength?: number
  630 + precision?: number
  631 + scale?: number
  632 + targetEntity?: string
  633 + allowedValues?: string[]
  634 +}
  635 +
  636 +export interface CustomFieldDef {
  637 + key: string
  638 + targetEntity: string
  639 + type: CustomFieldType
  640 + required: boolean
  641 + pii: boolean
  642 + labelTranslations: Record<string, string>
  643 + source?: string
  644 +}
  645 +
  646 +export interface MetadataEntity {
  647 + name: string
  648 + pbc: string
  649 + table: string
  650 + description?: string
  651 + source?: string
  652 +}
  653 +
  654 +export interface MetadataPermission {
  655 + key: string
  656 + description: string
  657 + source?: string
  658 +}
  659 +```
  660 +
  661 +- [ ] **Step 3: Add API client functions**
  662 +
  663 +Add to `web/src/api/client.ts`:
  664 +
  665 +```typescript
  666 +export const metadata = {
  667 + entities: () => apiFetch<MetadataEntity[]>('/api/v1/_meta/metadata/entities'),
  668 + permissions: () => apiFetch<MetadataPermission[]>('/api/v1/_meta/metadata/permissions'),
  669 + menus: () => apiFetch<any[]>('/api/v1/_meta/metadata/menus'),
  670 + customFields: () => apiFetch<CustomFieldDef[]>('/api/v1/_meta/metadata/custom-fields'),
  671 + customFieldsFor: (entity: string) => apiFetch<CustomFieldDef[]>(`/api/v1/_meta/metadata/custom-fields/${entity}`),
  672 + listForms: () => apiFetch<FormDefinition[]>('/api/v1/_meta/metadata/forms'),
  673 + getForm: (slug: string) => apiFetch<FormDefinition>(`/api/v1/_meta/metadata/forms/${slug}`),
  674 + saveForm: (slug: string, body: Omit<FormDefinition, 'source'>) =>
  675 + apiFetch<FormDefinition>(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
  676 + deleteForm: (slug: string) =>
  677 + apiFetch<void>(`/api/v1/_meta/metadata/forms/${slug}`, { method: 'DELETE' }, false),
  678 + listListViews: () => apiFetch<ListViewDefinition[]>('/api/v1/_meta/metadata/list-views'),
  679 + getListView: (slug: string) => apiFetch<ListViewDefinition>(`/api/v1/_meta/metadata/list-views/${slug}`),
  680 + saveListView: (slug: string, body: Omit<ListViewDefinition, 'source'>) =>
  681 + apiFetch<ListViewDefinition>(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'PUT', body: JSON.stringify(body) }),
  682 + deleteListView: (slug: string) =>
  683 + apiFetch<void>(`/api/v1/_meta/metadata/list-views/${slug}`, { method: 'DELETE' }, false),
  684 + createCustomField: (body: Omit<CustomFieldDef, 'source'>) =>
  685 + apiFetch<CustomFieldDef>('/api/v1/_meta/metadata/custom-fields', { method: 'POST', body: JSON.stringify(body) }),
  686 + updateCustomField: (key: string, body: Omit<CustomFieldDef, 'source'>) =>
  687 + apiFetch<CustomFieldDef>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'PUT', body: JSON.stringify(body) }),
  688 + deleteCustomField: (key: string) =>
  689 + apiFetch<void>(`/api/v1/_meta/metadata/custom-fields/${key}`, { method: 'DELETE' }, false),
  690 +}
  691 +```
  692 +
  693 +- [ ] **Step 4: Add i18n keys**
  694 +
  695 +Add to both `en` and `zhCN` objects in `messages.ts`:
  696 +
  697 +```typescript
  698 +// en additions
  699 +'nav.metadataAdmin': 'Metadata',
  700 +'page.metadataAdmin.title': 'Metadata Admin',
  701 +'tab.entities': 'Entities',
  702 +'tab.customFields': 'Custom Fields',
  703 +'tab.permissions': 'Permissions',
  704 +'tab.menus': 'Menus',
  705 +'tab.forms': 'Forms',
  706 +'tab.listViews': 'List Views',
  707 +'page.formDesigner.title': 'Form Designer',
  708 +'page.listViewDesigner.title': 'List View Designer',
  709 +'action.addField': 'Add Field',
  710 +'action.addSection': 'Add Section',
  711 +'action.discard': 'Discard',
  712 +'action.delete': 'Delete',
  713 +'label.slug': 'Slug',
  714 +'label.entity': 'Entity',
  715 +'label.purpose': 'Purpose',
  716 +'label.preview': 'Preview',
  717 +'label.source': 'Source',
  718 +'label.columns': 'Columns',
  719 +'label.filters': 'Filters',
  720 +'label.sorting': 'Sorting',
  721 +'label.pageSize': 'Page Size',
  722 +'label.fieldKey': 'Field Key',
  723 +'label.targetEntity': 'Target Entity',
  724 +'label.fieldType': 'Field Type',
  725 +'action.newCustomField': 'New Custom Field',
  726 +'confirm.delete': 'Are you sure?',
  727 +```
  728 +
  729 +```typescript
  730 +// zhCN additions
  731 +'nav.metadataAdmin': '元数据',
  732 +'page.metadataAdmin.title': '元数据管理',
  733 +'tab.entities': '实体',
  734 +'tab.customFields': '自定义字段',
  735 +'tab.permissions': '权限',
  736 +'tab.menus': '菜单',
  737 +'tab.forms': '表单',
  738 +'tab.listViews': '列表视图',
  739 +'page.formDesigner.title': '表单设计器',
  740 +'page.listViewDesigner.title': '列表视图设计器',
  741 +'action.addField': '添加字段',
  742 +'action.addSection': '添加分区',
  743 +'action.discard': '放弃',
  744 +'action.delete': '删除',
  745 +'label.slug': '标识',
  746 +'label.entity': '实体',
  747 +'label.purpose': '用途',
  748 +'label.preview': '预览',
  749 +'label.source': '来源',
  750 +'label.columns': '列',
  751 +'label.filters': '筛选',
  752 +'label.sorting': '排序',
  753 +'label.pageSize': '每页行数',
  754 +'label.fieldKey': '字段键',
  755 +'label.targetEntity': '目标实体',
  756 +'label.fieldType': '字段类型',
  757 +'action.newCustomField': '新建自定义字段',
  758 +'confirm.delete': '确定删除?',
  759 +```
  760 +
  761 +- [ ] **Step 5: Add routes and nav entry**
  762 +
  763 +Add to `App.tsx` imports and routes. Add "Metadata" nav entry to `AppLayout.tsx` in the System section.
  764 +
  765 +- [ ] **Step 6: Commit**
  766 +
  767 +```bash
  768 +git add web/
  769 +git commit -m "feat(web): @rjsf deps + metadata API client + types + i18n + routes"
  770 +```
  771 +
  772 +---
  773 +
  774 +## Task 5: Build MetadataFormRenderer + VibeErp theme + custom widgets
  775 +
  776 +**Files:**
  777 +- Create: `web/src/components/form-widgets/vibeErpTheme.tsx`
  778 +- Create: `web/src/components/MetadataFormRenderer.tsx`
  779 +- Create: `web/src/components/form-widgets/PartnerPicker.tsx`
  780 +- Create: `web/src/components/form-widgets/ItemPicker.tsx`
  781 +- Create: `web/src/components/form-widgets/UomSelector.tsx`
  782 +- Create: `web/src/components/form-widgets/LocationPicker.tsx`
  783 +- Create: `web/src/components/form-widgets/MoneyInput.tsx`
  784 +- Create: `web/src/components/form-widgets/QuantityInput.tsx`
  785 +- Create: `web/src/components/form-widgets/index.ts`
  786 +
  787 +- [ ] **Step 1: Create the VibeErp theme**
  788 +
  789 +Create `vibeErpTheme.tsx` that exports custom @rjsf templates wrapping fields with existing Tailwind classes:
  790 +- Labels: `"block text-sm font-medium text-slate-700"`
  791 +- Inputs: `"mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"`
  792 +- Layout: `"grid grid-cols-1 gap-4 sm:grid-cols-2"`
  793 +- Submit: `"btn-primary"`
  794 +- Errors: red text under invalid fields
  795 +
  796 +- [ ] **Step 2: Create the widget registry and picker widgets**
  797 +
  798 +Each picker widget (PartnerPicker, ItemPicker, UomSelector, LocationPicker) follows the same pattern:
  799 +1. Fetches data from the API on mount using the existing `client.ts` functions
  800 +2. Renders a `<select>` dropdown with Tailwind styling
  801 +3. Calls `props.onChange(selectedValue)` from @rjsf `WidgetProps`
  802 +
  803 +MoneyInput and QuantityInput are number inputs with `step="0.01"`.
  804 +
  805 +Export all widgets from `index.ts` as a `vibeWidgets: RegistryWidgetsType` map.
  806 +
  807 +- [ ] **Step 3: Create MetadataFormRenderer**
  808 +
  809 +The component:
  810 +1. Fetches form definition from `GET /api/v1/_meta/metadata/forms/{slug}`
  811 +2. Renders `<Form>` from `@rjsf/core` with the fetched schema, theme, and widgets
  812 +3. Handles loading state, error state, and read-only mode
  813 +4. Evaluates `ui:visible` conditions in the uiSchema for conditional field visibility
  814 +
  815 +- [ ] **Step 4: Verify build compiles**
  816 +
  817 +```bash
  818 +cd web && npm run build && cd ..
  819 +git add web/src/components/
  820 +git commit -m "feat(web): MetadataFormRenderer + VibeErp theme + 6 custom widgets"
  821 +```
  822 +
  823 +---
  824 +
  825 +## Task 6: Build FormDesigner page
  826 +
  827 +**Files:**
  828 +- Create: `web/src/pages/FormDesignerPage.tsx`
  829 +
  830 +- [ ] **Step 1: Build the structured property editor**
  831 +
  832 +Two-panel layout:
  833 +- **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.
  834 +- **Right panel**: Live preview using `<MetadataFormRenderer>` that re-renders as the field list changes.
  835 +- **Top bar**: Title input, entity selector, purpose selector, Save/Discard buttons.
  836 +
  837 +State managed as `DesignerField[]` → converted to JSON Schema + UI Schema via a pure function `buildFormDefinition()`.
  838 +
  839 +Save calls `PUT /api/v1/_meta/metadata/forms/{slug}`.
  840 +
  841 +- [ ] **Step 2: Verify and commit**
  842 +
  843 +```bash
  844 +cd web && npm run build && cd ..
  845 +git add web/src/pages/FormDesignerPage.tsx
  846 +git commit -m "feat(web): form designer — structured property editor with live preview"
  847 +```
  848 +
  849 +---
  850 +
  851 +## Task 7: Build ListViewDesigner page
  852 +
  853 +**Files:**
  854 +- Create: `web/src/pages/ListViewDesignerPage.tsx`
  855 +
  856 +- [ ] **Step 1: Build the list view configuration editor**
  857 +
  858 +Sections:
  859 +- **Columns**: Table of available fields with show/hide checkboxes, label editing, format selector, sortable toggle, up/down reorder.
  860 +- **Sorting**: Default sort column + direction.
  861 +- **Filters**: Add filterable fields with operator selector.
  862 +- **Page size**: Number input.
  863 +- **Preview**: Mock DataTable with the current column configuration.
  864 +
  865 +Save calls `PUT /api/v1/_meta/metadata/list-views/{slug}`.
  866 +
  867 +- [ ] **Step 2: Verify and commit**
  868 +
  869 +```bash
  870 +cd web && npm run build && cd ..
  871 +git add web/src/pages/ListViewDesignerPage.tsx
  872 +git commit -m "feat(web): list view designer — column/filter/sort configuration"
  873 +```
  874 +
  875 +---
  876 +
  877 +## Task 8: Build MetadataAdmin page
  878 +
  879 +**Files:**
  880 +- Create: `web/src/pages/MetadataAdminPage.tsx`
  881 +
  882 +- [ ] **Step 1: Build the tabbed admin page**
  883 +
  884 +Six tabs using a simple tab bar component:
  885 +1. **Entities** — read-only DataTable with source badge
  886 +2. **Custom Fields** — DataTable + "New Custom Field" button + inline create form + edit/delete for source='user' rows
  887 +3. **Permissions** — read-only DataTable with source badge
  888 +4. **Menus** — read-only DataTable with source badge
  889 +5. **Forms** — DataTable + "New Form" button (navigates to designer) + delete for source='user'
  890 +6. **List Views** — DataTable + "New List View" button (navigates to designer) + delete for source='user'
  891 +
  892 +Source badge: small colored pill (`core` = blue, `plugin:*` = amber, `user` = emerald).
  893 +
  894 +Custom field inline editor: target entity dropdown, key input, type kind dropdown, required/PII checkboxes, label translation inputs (en + zh-CN).
  895 +
  896 +- [ ] **Step 2: Verify and commit**
  897 +
  898 +```bash
  899 +cd web && npm run build && cd ..
  900 +git add web/src/pages/MetadataAdminPage.tsx
  901 +git commit -m "feat(web): metadata admin — tabbed CRUD for entities, fields, forms, list views"
  902 +```
  903 +
  904 +---
  905 +
  906 +## Task 9: Full build + smoke test + version bump
  907 +
  908 +**Files:**
  909 +- Modify: `gradle.properties` (version bump)
  910 +- Modify: `PROGRESS.md` (update status)
  911 +
  912 +- [ ] **Step 1: Run full Gradle build**
  913 +
  914 +```bash
  915 +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew build
  916 +```
  917 +
  918 +All tests must pass.
  919 +
  920 +- [ ] **Step 2: Boot and smoke test**
  921 +
  922 +```bash
  923 +docker compose up -d db
  924 +JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :distribution:bootRun
  925 +```
  926 +
  927 +Smoke test checklist:
  928 +1. Log in as admin
  929 +2. Navigate to Metadata Admin (`/admin/metadata`)
  930 +3. Verify all 6 tabs load with data (entities, custom fields, permissions, menus, forms, list views)
  931 +4. Verify source badges show correctly (core = blue, plugin:printing-shop = amber)
  932 +5. Create a custom field (source='user') through the Custom Fields tab
  933 +6. Verify the new field appears in DynamicExtFields on the relevant entity's create page
  934 +7. Delete the custom field, verify it disappears
  935 +8. Navigate to Forms tab, verify `plate-approval-task` form shows (from plugin)
  936 +9. Create a new user form via the form designer
  937 +10. Verify live preview renders correctly
  938 +11. Save and verify it appears in the Forms tab with source='user'
  939 +
  940 +- [ ] **Step 3: Bump version and update PROGRESS.md**
  941 +
  942 +Bump `vibeerp.version` in `gradle.properties` to `0.33.0-SNAPSHOT`.
  943 +Update PROGRESS.md: mark P3.2, P3.3, P3.6, R3 as done with commit refs.
  944 +
  945 +- [ ] **Step 4: Commit and push**
  946 +
  947 +```bash
  948 +git add -A
  949 +git commit -m "feat(metadata): P3.2 form renderer + P3.3 form designer + P3.6 list view designer + R3 metadata admin"
  950 +git push origin main
  951 +```
  952 +
  953 +- [ ] **Step 5: Verify CI green**
  954 +
  955 +```bash
  956 +gh run list --limit 1
  957 +```
... ...