2026-04-10-metadata-forms-listviews.md 34.6 KB

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:

@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:

  1. Add to wipeBySource():

    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():

    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.

  1. Call them from doLoad() after the existing insert calls:

    insertForms(merged.forms, source, now)
    insertListViews(merged.listViews, source, now)
    
  2. Add formCount and listViewCount to LoadResult.

  • 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.kt

  • Step 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.yml

  • Step 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.tsx

  • Step 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.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 <select> dropdown with Tailwind styling
  3. Calls props.onChange(selectedValue) from @rjsf WidgetProps

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:

  1. Fetches form definition from GET /api/v1/_meta/metadata/forms/{slug}
  2. Renders <Form> from @rjsf/core with the fetched schema, theme, and widgets
  3. Handles loading state, error state, and read-only mode
  4. Evaluates ui:visible conditions 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.tsx

  • Step 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.tsx

  • Step 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.tsx

  • Step 1: Build the tabbed admin page

Six tabs using a simple tab bar component:

  1. Entities — read-only DataTable with source badge
  2. Custom Fields — DataTable + "New Custom Field" button + inline create form + edit/delete for source='user' rows
  3. Permissions — read-only DataTable with source badge
  4. Menus — read-only DataTable with source badge
  5. Forms — DataTable + "New Form" button (navigates to designer) + delete for source='user'
  6. 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:

  1. Log in as admin
  2. Navigate to Metadata Admin (/admin/metadata)
  3. Verify all 6 tabs load with data (entities, custom fields, permissions, menus, forms, list views)
  4. Verify source badges show correctly (core = blue, plugin:printing-shop = amber)
  5. Create a custom field (source='user') through the Custom Fields tab
  6. Verify the new field appears in DynamicExtFields on the relevant entity's create page
  7. Delete the custom field, verify it disappears
  8. Navigate to Forms tab, verify plate-approval-task form shows (from plugin)
  9. Create a new user form via the form designer
  10. Verify live preview renders correctly
  11. 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