Commit bebca2a691af0dd752eb240a3cda47bcb65e4c2c

Authored by zichun
1 parent 025469cf

feat(metadata): extend YAML schema + loader for forms and list views

platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt
... ... @@ -152,6 +152,8 @@ class MetadataLoader(
152 152 permissions = files.flatMap { it.parsed.permissions },
153 153 menus = files.flatMap { it.parsed.menus },
154 154 customFields = files.flatMap { it.parsed.customFields },
  155 + forms = files.flatMap { it.parsed.forms },
  156 + listViews = files.flatMap { it.parsed.listViews },
155 157 )
156 158  
157 159 wipeBySource(source)
... ... @@ -159,11 +161,13 @@ class MetadataLoader(
159 161 insertPermissions(source, merged)
160 162 insertMenus(source, merged)
161 163 insertCustomFields(source, merged)
  164 + insertForms(source, merged)
  165 + insertListViews(source, merged)
162 166  
163 167 log.info(
164   - "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields from {} file(s)",
  168 + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields, {} forms, {} list views from {} file(s)",
165 169 source, merged.entities.size, merged.permissions.size, merged.menus.size,
166   - merged.customFields.size, files.size,
  170 + merged.customFields.size, merged.forms.size, merged.listViews.size, files.size,
167 171 )
168 172  
169 173 return LoadResult(
... ... @@ -172,6 +176,8 @@ class MetadataLoader(
172 176 permissionCount = merged.permissions.size,
173 177 menuCount = merged.menus.size,
174 178 customFieldCount = merged.customFields.size,
  179 + formCount = merged.forms.size,
  180 + listViewCount = merged.listViews.size,
175 181 files = files.map { it.url },
176 182 )
177 183 }
... ... @@ -186,6 +192,8 @@ class MetadataLoader(
186 192 val permissionCount: Int,
187 193 val menuCount: Int,
188 194 val customFieldCount: Int = 0,
  195 + val formCount: Int = 0,
  196 + val listViewCount: Int = 0,
189 197 val files: List<String>,
190 198 )
191 199  
... ... @@ -197,6 +205,8 @@ class MetadataLoader(
197 205 jdbc.update("DELETE FROM metadata__permission WHERE source = :source", params)
198 206 jdbc.update("DELETE FROM metadata__menu WHERE source = :source", params)
199 207 jdbc.update("DELETE FROM metadata__custom_field WHERE source = :source", params)
  208 + jdbc.update("DELETE FROM metadata__form WHERE source = :source", params)
  209 + jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params)
200 210 }
201 211  
202 212 private fun insertEntities(source: String, file: MetadataYamlFile) {
... ... @@ -267,6 +277,40 @@ class MetadataLoader(
267 277 }
268 278 }
269 279  
  280 + private fun insertForms(source: String, file: MetadataYamlFile) {
  281 + val now = Timestamp.from(Instant.now())
  282 + for (form in file.forms) {
  283 + jdbc.update(
  284 + """
  285 + INSERT INTO metadata__form (id, source, payload, created_at, updated_at)
  286 + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)
  287 + """.trimIndent(),
  288 + MapSqlParameterSource()
  289 + .addValue("id", UUID.randomUUID())
  290 + .addValue("source", source)
  291 + .addValue("payload", jsonMapper.writeValueAsString(form))
  292 + .addValue("now", now),
  293 + )
  294 + }
  295 + }
  296 +
  297 + private fun insertListViews(source: String, file: MetadataYamlFile) {
  298 + val now = Timestamp.from(Instant.now())
  299 + for (listView in file.listViews) {
  300 + jdbc.update(
  301 + """
  302 + INSERT INTO metadata__list_view (id, source, payload, created_at, updated_at)
  303 + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now)
  304 + """.trimIndent(),
  305 + MapSqlParameterSource()
  306 + .addValue("id", UUID.randomUUID())
  307 + .addValue("source", source)
  308 + .addValue("payload", jsonMapper.writeValueAsString(listView))
  309 + .addValue("now", now),
  310 + )
  311 + }
  312 + }
  313 +
270 314 private data class ParsedYaml(
271 315 val url: String,
272 316 val parsed: MetadataYamlFile,
... ...
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt
... ... @@ -32,6 +32,8 @@ data class MetadataYamlFile(
32 32 val permissions: List<PermissionYaml> = emptyList(),
33 33 val menus: List<MenuYaml> = emptyList(),
34 34 val customFields: List<CustomFieldYaml> = emptyList(),
  35 + val forms: List<FormYaml> = emptyList(),
  36 + val listViews: List<ListViewYaml> = emptyList(),
35 37 )
36 38  
37 39 /**
... ... @@ -204,3 +206,115 @@ data class CustomFieldTypeYaml(
204 206 val targetEntity: String? = null, // reference
205 207 val allowedValues: List<String>? = null, // enum
206 208 )
  209 +
  210 +/**
  211 + * A form definition attached to an entity.
  212 + *
  213 + * Forms are declarative descriptions of how a create/edit/view screen
  214 + * should render for a particular entity. The SPA reads these from
  215 + * `GET /api/v1/_meta/metadata` and uses `jsonSchema` for validation
  216 + * and `uiSchema` for layout hints (field ordering, grouping, widgets).
  217 + *
  218 + * @property slug Stable identifier. Convention: `<pbc>-<entity>-<purpose>`
  219 + * (e.g. `catalog-item-edit`, `orders-sales-order-create`).
  220 + * @property entityName The entity this form belongs to (matches
  221 + * `EntityYaml.name`).
  222 + * @property title Human-readable title shown above the form.
  223 + * @property purpose The form's intent: `edit`, `create`, `view`, or a
  224 + * custom string. Multiple forms per entity are allowed (e.g. a
  225 + * simplified "quick create" vs. a full "edit").
  226 + * @property jsonSchema JSON Schema describing the fields and their
  227 + * validation rules (types, required, min/max, patterns).
  228 + * @property uiSchema Layout and widget hints consumed by the SPA's
  229 + * form renderer (field order, grouping, conditional visibility,
  230 + * widget overrides).
  231 + * @property version Schema version for forward-compat migration.
  232 + */
  233 +@JsonIgnoreProperties(ignoreUnknown = true)
  234 +data class FormYaml(
  235 + val slug: String = "",
  236 + val entityName: String = "",
  237 + val title: String = "",
  238 + val purpose: String = "edit",
  239 + val jsonSchema: Map<String, Any?> = emptyMap(),
  240 + val uiSchema: Map<String, Any?> = emptyMap(),
  241 + val version: Int = 1,
  242 +)
  243 +
  244 +/**
  245 + * A list view definition for an entity.
  246 + *
  247 + * List views describe which columns appear in a tabular listing page,
  248 + * their default sort order, available quick-filters, and page size.
  249 + * The SPA reads these from metadata and renders the list accordingly.
  250 + *
  251 + * @property slug Stable identifier. Convention:
  252 + * `<pbc>-<entity>-list` (e.g. `catalog-item-list`).
  253 + * @property entityName The entity this list view belongs to (matches
  254 + * `EntityYaml.name`).
  255 + * @property title Human-readable title shown above the list.
  256 + * @property columns Ordered list of columns to display.
  257 + * @property defaultSort Default sort field and direction.
  258 + * @property filters Quick-filter controls shown above the list.
  259 + * @property pageSize Number of rows per page (default 25).
  260 + * @property version Schema version for forward-compat migration.
  261 + */
  262 +@JsonIgnoreProperties(ignoreUnknown = true)
  263 +data class ListViewYaml(
  264 + val slug: String = "",
  265 + val entityName: String = "",
  266 + val title: String = "",
  267 + val columns: List<ListViewColumnYaml> = emptyList(),
  268 + val defaultSort: ListViewSortYaml? = null,
  269 + val filters: List<ListViewFilterYaml> = emptyList(),
  270 + val pageSize: Int = 25,
  271 + val version: Int = 1,
  272 +)
  273 +
  274 +/**
  275 + * A single column in a [ListViewYaml].
  276 + *
  277 + * @property field The entity field (or custom field key) this column
  278 + * displays.
  279 + * @property label Display header for the column.
  280 + * @property width CSS width hint (e.g. `"120px"`, `"20%"`). `null`
  281 + * means auto-size.
  282 + * @property sortable Whether the column supports click-to-sort.
  283 + * @property format Optional format hint consumed by the SPA renderer
  284 + * (e.g. `"date"`, `"currency"`, `"percentage"`).
  285 + */
  286 +@JsonIgnoreProperties(ignoreUnknown = true)
  287 +data class ListViewColumnYaml(
  288 + val field: String = "",
  289 + val label: String = "",
  290 + val width: String? = null,
  291 + val sortable: Boolean = true,
  292 + val format: String? = null,
  293 +)
  294 +
  295 +/**
  296 + * Default sort specification for a [ListViewYaml].
  297 + *
  298 + * @property field The field to sort by.
  299 + * @property direction `"asc"` or `"desc"`.
  300 + */
  301 +@JsonIgnoreProperties(ignoreUnknown = true)
  302 +data class ListViewSortYaml(
  303 + val field: String = "",
  304 + val direction: String = "asc",
  305 +)
  306 +
  307 +/**
  308 + * A quick-filter control in a [ListViewYaml].
  309 + *
  310 + * @property field The entity field this filter operates on.
  311 + * @property operator The comparison operator (`eq`, `ne`, `lt`, `gt`,
  312 + * `le`, `ge`, `contains`, `startsWith`, `in`).
  313 + * @property label Display label for the filter control.
  314 + */
  315 +@JsonIgnoreProperties(ignoreUnknown = true)
  316 +data class ListViewFilterYaml(
  317 + val field: String = "",
  318 + val operator: String = "eq",
  319 + val label: String = "",
  320 +)
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt
... ... @@ -140,6 +140,134 @@ class MetadataLoaderTest {
140 140 .isInstanceOf(IllegalArgumentException::class)
141 141 }
142 142  
  143 + @Test
  144 + fun `loadFromPluginJar with forms issues INSERT into metadata__form`() {
  145 + val tempDir = Files.createTempDirectory("metadata-loader-test")
  146 + val jar = buildMetadataJar(
  147 + tempDir,
  148 + "META-INF/vibe-erp/metadata/test.yml",
  149 + """
  150 + forms:
  151 + - slug: catalog-item-edit
  152 + entityName: Item
  153 + title: Edit Item
  154 + purpose: edit
  155 + jsonSchema:
  156 + type: object
  157 + uiSchema: {}
  158 + """.trimIndent(),
  159 + )
  160 +
  161 + val captured = slot<MapSqlParameterSource>()
  162 + every {
  163 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__form") }, capture(captured))
  164 + } returns 1
  165 +
  166 + val result = loader.loadFromPluginJar("test-plugin", jar)
  167 +
  168 + assertThat(result.formCount).isEqualTo(1)
  169 + assertThat(result.entityCount).isEqualTo(0)
  170 + assertThat(result.listViewCount).isEqualTo(0)
  171 + verify(exactly = 1) {
  172 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__form") }, any<MapSqlParameterSource>())
  173 + }
  174 +
  175 + val params = captured.captured
  176 + assertThat(params.getValue("source") as String).isEqualTo("plugin:test-plugin")
  177 + val payload = params.getValue("payload") as String
  178 + assertThat(payload).contains("catalog-item-edit")
  179 + assertThat(payload).contains("Item")
  180 + }
  181 +
  182 + @Test
  183 + fun `loadFromPluginJar with listViews issues INSERT into metadata__list_view`() {
  184 + val tempDir = Files.createTempDirectory("metadata-loader-test")
  185 + val jar = buildMetadataJar(
  186 + tempDir,
  187 + "META-INF/vibe-erp/metadata/test.yml",
  188 + """
  189 + listViews:
  190 + - slug: catalog-item-list
  191 + entityName: Item
  192 + title: Items
  193 + columns:
  194 + - field: name
  195 + label: Name
  196 + defaultSort:
  197 + field: name
  198 + direction: asc
  199 + pageSize: 50
  200 + """.trimIndent(),
  201 + )
  202 +
  203 + val captured = slot<MapSqlParameterSource>()
  204 + every {
  205 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__list_view") }, capture(captured))
  206 + } returns 1
  207 +
  208 + val result = loader.loadFromPluginJar("test-plugin", jar)
  209 +
  210 + assertThat(result.listViewCount).isEqualTo(1)
  211 + assertThat(result.formCount).isEqualTo(0)
  212 + verify(exactly = 1) {
  213 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__list_view") }, any<MapSqlParameterSource>())
  214 + }
  215 +
  216 + val params = captured.captured
  217 + assertThat(params.getValue("source") as String).isEqualTo("plugin:test-plugin")
  218 + val payload = params.getValue("payload") as String
  219 + assertThat(payload).contains("catalog-item-list")
  220 + assertThat(payload).contains("Item")
  221 + }
  222 +
  223 + @Test
  224 + fun `loadFromPluginJar with all sections issues inserts into all tables`() {
  225 + val tempDir = Files.createTempDirectory("metadata-loader-test")
  226 + val jar = buildMetadataJar(
  227 + tempDir,
  228 + "META-INF/vibe-erp/metadata/everything.yml",
  229 + """
  230 + entities:
  231 + - name: User
  232 + pbc: identity
  233 + table: identity__user
  234 + permissions:
  235 + - key: identity.user.read
  236 + description: Read users
  237 + menus:
  238 + - path: /identity/users
  239 + label: Users
  240 + section: System
  241 + order: 100
  242 + forms:
  243 + - slug: identity-user-edit
  244 + entityName: User
  245 + title: Edit User
  246 + listViews:
  247 + - slug: identity-user-list
  248 + entityName: User
  249 + title: Users
  250 + columns:
  251 + - field: name
  252 + label: Name
  253 + """.trimIndent(),
  254 + )
  255 +
  256 + val result = loader.loadFromPluginJar("test-plugin", jar)
  257 +
  258 + assertThat(result.entityCount).isEqualTo(1)
  259 + assertThat(result.permissionCount).isEqualTo(1)
  260 + assertThat(result.menuCount).isEqualTo(1)
  261 + assertThat(result.formCount).isEqualTo(1)
  262 + assertThat(result.listViewCount).isEqualTo(1)
  263 + verify(exactly = 1) {
  264 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__form") }, any<MapSqlParameterSource>())
  265 + }
  266 + verify(exactly = 1) {
  267 + jdbc.update(match<String> { it.contains("INSERT INTO metadata__list_view") }, any<MapSqlParameterSource>())
  268 + }
  269 + }
  270 +
143 271 // ─── helpers ───────────────────────────────────────────────────
144 272  
145 273 private fun buildMetadataJar(
... ...
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt
... ... @@ -103,15 +103,15 @@ class MetadataYamlParseTest {
103 103 @Test
104 104 fun `unknown top-level keys are ignored`() {
105 105 // Forward-compat: a future plug-in built against a newer YAML
106   - // schema (with `forms`, `workflows`, `rules`, …) loads cleanly
107   - // on an older host that doesn't know those keys yet.
  106 + // schema (with `workflows`, `rules`, …) loads cleanly on an
  107 + // older host that doesn't know those keys yet.
108 108 val yaml = """
109 109 entities:
110 110 - name: Foo
111 111 pbc: bar
112 112 table: bar__foo
113   - forms:
114   - - id: future-form
  113 + futureSection:
  114 + - id: something-unknown
115 115 unknown_section:
116 116 - foo
117 117 """.trimIndent()
... ... @@ -146,4 +146,89 @@ class MetadataYamlParseTest {
146 146 assertThat(parsed.menus[0].section).isEqualTo("Catalog")
147 147 assertThat(parsed.menus[0].order).isEqualTo(200)
148 148 }
  149 +
  150 + @Test
  151 + fun `forms section parses with slug, entityName, jsonSchema, uiSchema`() {
  152 + val yaml = """
  153 + forms:
  154 + - slug: catalog-item-edit
  155 + entityName: Item
  156 + title: Edit Item
  157 + purpose: edit
  158 + jsonSchema:
  159 + type: object
  160 + properties:
  161 + name:
  162 + type: string
  163 + required:
  164 + - name
  165 + uiSchema:
  166 + "ui:order":
  167 + - name
  168 + version: 2
  169 + """.trimIndent()
  170 +
  171 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  172 +
  173 + assertThat(parsed.forms).hasSize(1)
  174 + val form = parsed.forms[0]
  175 + assertThat(form.slug).isEqualTo("catalog-item-edit")
  176 + assertThat(form.entityName).isEqualTo("Item")
  177 + assertThat(form.title).isEqualTo("Edit Item")
  178 + assertThat(form.purpose).isEqualTo("edit")
  179 + assertThat(form.version).isEqualTo(2)
  180 + assertThat(form.jsonSchema["type"]).isEqualTo("object")
  181 + assertThat(form.uiSchema).isNotNull()
  182 + }
  183 +
  184 + @Test
  185 + fun `listViews section parses with columns and filters`() {
  186 + val yaml = """
  187 + listViews:
  188 + - slug: catalog-item-list
  189 + entityName: Item
  190 + title: Items
  191 + columns:
  192 + - field: name
  193 + label: Name
  194 + width: "200px"
  195 + sortable: true
  196 + - field: sku
  197 + label: SKU
  198 + sortable: false
  199 + format: uppercase
  200 + defaultSort:
  201 + field: name
  202 + direction: asc
  203 + filters:
  204 + - field: status
  205 + operator: eq
  206 + label: Status
  207 + - field: name
  208 + operator: contains
  209 + label: Name
  210 + pageSize: 50
  211 + version: 1
  212 + """.trimIndent()
  213 +
  214 + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java)
  215 +
  216 + assertThat(parsed.listViews).hasSize(1)
  217 + val lv = parsed.listViews[0]
  218 + assertThat(lv.slug).isEqualTo("catalog-item-list")
  219 + assertThat(lv.entityName).isEqualTo("Item")
  220 + assertThat(lv.title).isEqualTo("Items")
  221 + assertThat(lv.pageSize).isEqualTo(50)
  222 + assertThat(lv.columns).hasSize(2)
  223 + assertThat(lv.columns[0].field).isEqualTo("name")
  224 + assertThat(lv.columns[0].width).isEqualTo("200px")
  225 + assertThat(lv.columns[1].sortable).isEqualTo(false)
  226 + assertThat(lv.columns[1].format).isEqualTo("uppercase")
  227 + assertThat(lv.defaultSort).isNotNull()
  228 + assertThat(lv.defaultSort!!.field).isEqualTo("name")
  229 + assertThat(lv.defaultSort!!.direction).isEqualTo("asc")
  230 + assertThat(lv.filters).hasSize(2)
  231 + assertThat(lv.filters[0].operator).isEqualTo("eq")
  232 + assertThat(lv.filters[1].operator).isEqualTo("contains")
  233 + }
149 234 }
... ...