Commit 025469cf1365588503d76019f579f7f5caa0a5ce
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.
Showing
1 changed file
with
957 additions
and
0 deletions
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 | +``` | ... | ... |