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 | +``` |