diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt index d3c173f..2be348c 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt @@ -152,6 +152,8 @@ class MetadataLoader( permissions = files.flatMap { it.parsed.permissions }, menus = files.flatMap { it.parsed.menus }, customFields = files.flatMap { it.parsed.customFields }, + forms = files.flatMap { it.parsed.forms }, + listViews = files.flatMap { it.parsed.listViews }, ) wipeBySource(source) @@ -159,11 +161,13 @@ class MetadataLoader( insertPermissions(source, merged) insertMenus(source, merged) insertCustomFields(source, merged) + insertForms(source, merged) + insertListViews(source, merged) log.info( - "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields from {} file(s)", + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields, {} forms, {} list views from {} file(s)", source, merged.entities.size, merged.permissions.size, merged.menus.size, - merged.customFields.size, files.size, + merged.customFields.size, merged.forms.size, merged.listViews.size, files.size, ) return LoadResult( @@ -172,6 +176,8 @@ class MetadataLoader( permissionCount = merged.permissions.size, menuCount = merged.menus.size, customFieldCount = merged.customFields.size, + formCount = merged.forms.size, + listViewCount = merged.listViews.size, files = files.map { it.url }, ) } @@ -186,6 +192,8 @@ class MetadataLoader( val permissionCount: Int, val menuCount: Int, val customFieldCount: Int = 0, + val formCount: Int = 0, + val listViewCount: Int = 0, val files: List, ) @@ -197,6 +205,8 @@ class MetadataLoader( jdbc.update("DELETE FROM metadata__permission WHERE source = :source", params) jdbc.update("DELETE FROM metadata__menu WHERE source = :source", params) jdbc.update("DELETE FROM metadata__custom_field WHERE source = :source", params) + jdbc.update("DELETE FROM metadata__form WHERE source = :source", params) + jdbc.update("DELETE FROM metadata__list_view WHERE source = :source", params) } private fun insertEntities(source: String, file: MetadataYamlFile) { @@ -267,6 +277,40 @@ class MetadataLoader( } } + private fun insertForms(source: String, file: MetadataYamlFile) { + val now = Timestamp.from(Instant.now()) + for (form in file.forms) { + jdbc.update( + """ + INSERT INTO metadata__form (id, source, payload, created_at, updated_at) + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) + """.trimIndent(), + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("source", source) + .addValue("payload", jsonMapper.writeValueAsString(form)) + .addValue("now", now), + ) + } + } + + private fun insertListViews(source: String, file: MetadataYamlFile) { + val now = Timestamp.from(Instant.now()) + for (listView in file.listViews) { + jdbc.update( + """ + INSERT INTO metadata__list_view (id, source, payload, created_at, updated_at) + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) + """.trimIndent(), + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("source", source) + .addValue("payload", jsonMapper.writeValueAsString(listView)) + .addValue("now", now), + ) + } + } + private data class ParsedYaml( val url: String, val parsed: MetadataYamlFile, diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt index acbd28b..49570ce 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt @@ -32,6 +32,8 @@ data class MetadataYamlFile( val permissions: List = emptyList(), val menus: List = emptyList(), val customFields: List = emptyList(), + val forms: List = emptyList(), + val listViews: List = emptyList(), ) /** @@ -204,3 +206,115 @@ data class CustomFieldTypeYaml( val targetEntity: String? = null, // reference val allowedValues: List? = null, // enum ) + +/** + * A form definition attached to an entity. + * + * Forms are declarative descriptions of how a create/edit/view screen + * should render for a particular entity. The SPA reads these from + * `GET /api/v1/_meta/metadata` and uses `jsonSchema` for validation + * and `uiSchema` for layout hints (field ordering, grouping, widgets). + * + * @property slug Stable identifier. Convention: `--` + * (e.g. `catalog-item-edit`, `orders-sales-order-create`). + * @property entityName The entity this form belongs to (matches + * `EntityYaml.name`). + * @property title Human-readable title shown above the form. + * @property purpose The form's intent: `edit`, `create`, `view`, or a + * custom string. Multiple forms per entity are allowed (e.g. a + * simplified "quick create" vs. a full "edit"). + * @property jsonSchema JSON Schema describing the fields and their + * validation rules (types, required, min/max, patterns). + * @property uiSchema Layout and widget hints consumed by the SPA's + * form renderer (field order, grouping, conditional visibility, + * widget overrides). + * @property version Schema version for forward-compat migration. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class FormYaml( + val slug: String = "", + val entityName: String = "", + val title: String = "", + val purpose: String = "edit", + val jsonSchema: Map = emptyMap(), + val uiSchema: Map = emptyMap(), + val version: Int = 1, +) + +/** + * A list view definition for an entity. + * + * List views describe which columns appear in a tabular listing page, + * their default sort order, available quick-filters, and page size. + * The SPA reads these from metadata and renders the list accordingly. + * + * @property slug Stable identifier. Convention: + * `--list` (e.g. `catalog-item-list`). + * @property entityName The entity this list view belongs to (matches + * `EntityYaml.name`). + * @property title Human-readable title shown above the list. + * @property columns Ordered list of columns to display. + * @property defaultSort Default sort field and direction. + * @property filters Quick-filter controls shown above the list. + * @property pageSize Number of rows per page (default 25). + * @property version Schema version for forward-compat migration. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ListViewYaml( + val slug: String = "", + val entityName: String = "", + val title: String = "", + val columns: List = emptyList(), + val defaultSort: ListViewSortYaml? = null, + val filters: List = emptyList(), + val pageSize: Int = 25, + val version: Int = 1, +) + +/** + * A single column in a [ListViewYaml]. + * + * @property field The entity field (or custom field key) this column + * displays. + * @property label Display header for the column. + * @property width CSS width hint (e.g. `"120px"`, `"20%"`). `null` + * means auto-size. + * @property sortable Whether the column supports click-to-sort. + * @property format Optional format hint consumed by the SPA renderer + * (e.g. `"date"`, `"currency"`, `"percentage"`). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ListViewColumnYaml( + val field: String = "", + val label: String = "", + val width: String? = null, + val sortable: Boolean = true, + val format: String? = null, +) + +/** + * Default sort specification for a [ListViewYaml]. + * + * @property field The field to sort by. + * @property direction `"asc"` or `"desc"`. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ListViewSortYaml( + val field: String = "", + val direction: String = "asc", +) + +/** + * A quick-filter control in a [ListViewYaml]. + * + * @property field The entity field this filter operates on. + * @property operator The comparison operator (`eq`, `ne`, `lt`, `gt`, + * `le`, `ge`, `contains`, `startsWith`, `in`). + * @property label Display label for the filter control. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class ListViewFilterYaml( + val field: String = "", + val operator: String = "eq", + val label: String = "", +) diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt index 4e499cd..f2305c4 100644 --- a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/MetadataLoaderTest.kt @@ -140,6 +140,134 @@ class MetadataLoaderTest { .isInstanceOf(IllegalArgumentException::class) } + @Test + fun `loadFromPluginJar with forms issues INSERT into metadata__form`() { + val tempDir = Files.createTempDirectory("metadata-loader-test") + val jar = buildMetadataJar( + tempDir, + "META-INF/vibe-erp/metadata/test.yml", + """ + forms: + - slug: catalog-item-edit + entityName: Item + title: Edit Item + purpose: edit + jsonSchema: + type: object + uiSchema: {} + """.trimIndent(), + ) + + val captured = slot() + every { + jdbc.update(match { it.contains("INSERT INTO metadata__form") }, capture(captured)) + } returns 1 + + val result = loader.loadFromPluginJar("test-plugin", jar) + + assertThat(result.formCount).isEqualTo(1) + assertThat(result.entityCount).isEqualTo(0) + assertThat(result.listViewCount).isEqualTo(0) + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__form") }, any()) + } + + val params = captured.captured + assertThat(params.getValue("source") as String).isEqualTo("plugin:test-plugin") + val payload = params.getValue("payload") as String + assertThat(payload).contains("catalog-item-edit") + assertThat(payload).contains("Item") + } + + @Test + fun `loadFromPluginJar with listViews issues INSERT into metadata__list_view`() { + val tempDir = Files.createTempDirectory("metadata-loader-test") + val jar = buildMetadataJar( + tempDir, + "META-INF/vibe-erp/metadata/test.yml", + """ + listViews: + - slug: catalog-item-list + entityName: Item + title: Items + columns: + - field: name + label: Name + defaultSort: + field: name + direction: asc + pageSize: 50 + """.trimIndent(), + ) + + val captured = slot() + every { + jdbc.update(match { it.contains("INSERT INTO metadata__list_view") }, capture(captured)) + } returns 1 + + val result = loader.loadFromPluginJar("test-plugin", jar) + + assertThat(result.listViewCount).isEqualTo(1) + assertThat(result.formCount).isEqualTo(0) + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__list_view") }, any()) + } + + val params = captured.captured + assertThat(params.getValue("source") as String).isEqualTo("plugin:test-plugin") + val payload = params.getValue("payload") as String + assertThat(payload).contains("catalog-item-list") + assertThat(payload).contains("Item") + } + + @Test + fun `loadFromPluginJar with all sections issues inserts into all tables`() { + val tempDir = Files.createTempDirectory("metadata-loader-test") + val jar = buildMetadataJar( + tempDir, + "META-INF/vibe-erp/metadata/everything.yml", + """ + entities: + - name: User + pbc: identity + table: identity__user + permissions: + - key: identity.user.read + description: Read users + menus: + - path: /identity/users + label: Users + section: System + order: 100 + forms: + - slug: identity-user-edit + entityName: User + title: Edit User + listViews: + - slug: identity-user-list + entityName: User + title: Users + columns: + - field: name + label: Name + """.trimIndent(), + ) + + val result = loader.loadFromPluginJar("test-plugin", jar) + + assertThat(result.entityCount).isEqualTo(1) + assertThat(result.permissionCount).isEqualTo(1) + assertThat(result.menuCount).isEqualTo(1) + assertThat(result.formCount).isEqualTo(1) + assertThat(result.listViewCount).isEqualTo(1) + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__form") }, any()) + } + verify(exactly = 1) { + jdbc.update(match { it.contains("INSERT INTO metadata__list_view") }, any()) + } + } + // ─── helpers ─────────────────────────────────────────────────── private fun buildMetadataJar( diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt index 738ef41..e3dd304 100644 --- a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYamlParseTest.kt @@ -103,15 +103,15 @@ class MetadataYamlParseTest { @Test fun `unknown top-level keys are ignored`() { // Forward-compat: a future plug-in built against a newer YAML - // schema (with `forms`, `workflows`, `rules`, …) loads cleanly - // on an older host that doesn't know those keys yet. + // schema (with `workflows`, `rules`, …) loads cleanly on an + // older host that doesn't know those keys yet. val yaml = """ entities: - name: Foo pbc: bar table: bar__foo - forms: - - id: future-form + futureSection: + - id: something-unknown unknown_section: - foo """.trimIndent() @@ -146,4 +146,89 @@ class MetadataYamlParseTest { assertThat(parsed.menus[0].section).isEqualTo("Catalog") assertThat(parsed.menus[0].order).isEqualTo(200) } + + @Test + fun `forms section parses with slug, entityName, jsonSchema, uiSchema`() { + val yaml = """ + forms: + - slug: catalog-item-edit + entityName: Item + title: Edit Item + purpose: edit + jsonSchema: + type: object + properties: + name: + type: string + required: + - name + uiSchema: + "ui:order": + - name + version: 2 + """.trimIndent() + + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + + assertThat(parsed.forms).hasSize(1) + val form = parsed.forms[0] + assertThat(form.slug).isEqualTo("catalog-item-edit") + assertThat(form.entityName).isEqualTo("Item") + assertThat(form.title).isEqualTo("Edit Item") + assertThat(form.purpose).isEqualTo("edit") + assertThat(form.version).isEqualTo(2) + assertThat(form.jsonSchema["type"]).isEqualTo("object") + assertThat(form.uiSchema).isNotNull() + } + + @Test + fun `listViews section parses with columns and filters`() { + val yaml = """ + listViews: + - slug: catalog-item-list + entityName: Item + title: Items + columns: + - field: name + label: Name + width: "200px" + sortable: true + - field: sku + label: SKU + sortable: false + format: uppercase + defaultSort: + field: name + direction: asc + filters: + - field: status + operator: eq + label: Status + - field: name + operator: contains + label: Name + pageSize: 50 + version: 1 + """.trimIndent() + + val parsed = mapper.readValue(yaml, MetadataYamlFile::class.java) + + assertThat(parsed.listViews).hasSize(1) + val lv = parsed.listViews[0] + assertThat(lv.slug).isEqualTo("catalog-item-list") + assertThat(lv.entityName).isEqualTo("Item") + assertThat(lv.title).isEqualTo("Items") + assertThat(lv.pageSize).isEqualTo(50) + assertThat(lv.columns).hasSize(2) + assertThat(lv.columns[0].field).isEqualTo("name") + assertThat(lv.columns[0].width).isEqualTo("200px") + assertThat(lv.columns[1].sortable).isEqualTo(false) + assertThat(lv.columns[1].format).isEqualTo("uppercase") + assertThat(lv.defaultSort).isNotNull() + assertThat(lv.defaultSort!!.field).isEqualTo("name") + assertThat(lv.defaultSort!!.direction).isEqualTo("asc") + assertThat(lv.filters).hasSize(2) + assertThat(lv.filters[0].operator).isEqualTo("eq") + assertThat(lv.filters[1].operator).isEqualTo("contains") + } }