From 64e9f1c2e1c17ee127889f78498b48fe0487e71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?SAL=C4=B0H=20=C3=96ZKARA?= Date: Mon, 15 Jun 2026 20:49:40 +0300 Subject: [PATCH] Update low-code JSON schemas and sorting guard for flat layouts Flatten dashboard and form descriptor schemas for identity-keyed placements, remove the obsolete dashboard row schema, and allow constant string indexer access in AbpDynamicSortingGuard so low-code dynamic sorting works with property bags. Co-authored-by: Cursor --- .../Services/AbpDynamicSortingGuard.cs | 29 ++++++- .../Services/AbpDynamicSortingGuard_Tests.cs | 40 ++++++++++ .../dashboard-descriptor.schema.json | 39 +++++---- .../dashboard-row-descriptor.schema.json | 37 --------- ...board-visualization-descriptor.schema.json | 18 ++++- .../definitions/form-descriptor.schema.json | 8 +- .../form-field-descriptor.schema.json | 4 +- .../form-layout-descriptor.schema.json | 79 ++++++++++--------- .../definitions/page-descriptor.schema.json | 27 +++---- .../definitions/page-type.schema.json | 2 +- 10 files changed, 165 insertions(+), 118 deletions(-) delete mode 100644 schemas/low-code/definitions/dashboard-row-descriptor.schema.json diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs index 63caa66af5..9546890786 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs @@ -14,6 +14,10 @@ namespace Volo.Abp.Application.Services; /// every OrderBy / ThenBy expression built from a user-supplied sorting string is /// constrained to plain property or field access. Methods, comparisons, ternaries /// and constants in the sort key are rejected with . +/// Property-bag / shadow-property access through a constant string indexer +/// (e.g. it["Prop"], Data["Prop"]) is treated as plain property access +/// and allowed, since it carries no side effects and is the canonical way to sort +/// dynamically-mapped entities. /// internal static class AbpDynamicSortingGuard { @@ -81,7 +85,23 @@ internal static class AbpDynamicSortingGuard private const string Message = "Sorting expression is not supported."; protected override Expression VisitMethodCall(MethodCallExpression node) - => throw new AbpValidationException(Message); + { + // Allow property-bag / shadow-property access through a constant string + // indexer (it["Prop"], Data["Prop"], mainEntity["Prop"], ...). This is a + // pure read of a named member, not an arbitrary method invocation, so it + // does not open the injection surface the guard protects against. + if (IsConstantStringIndexer(node)) + { + if (node.Object != null) + { + Visit(node.Object); + } + + return node; + } + + throw new AbpValidationException(Message); + } protected override Expression VisitBinary(BinaryExpression node) => throw new AbpValidationException(Message); @@ -91,5 +111,12 @@ internal static class AbpDynamicSortingGuard protected override Expression VisitConstant(ConstantExpression node) => throw new AbpValidationException(Message); + + private static bool IsConstantStringIndexer(MethodCallExpression node) + { + return node.Method.Name == "get_Item" + && node.Arguments.Count == 1 + && node.Arguments[0] is ConstantExpression { Value: string }; + } } } diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs index 0806a1d824..60f5dcd55a 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs @@ -257,6 +257,37 @@ public class AbpDynamicSortingGuard_Tests : AbpDddApplicationTestBase Should.NotThrow(() => children.OrderBy("Name desc, ExtraField asc").ToList()); } + [Theory] + [InlineData("Data[\"Score\"]")] // dictionary indexer with constant string key + [InlineData("Data[\"Score\"] desc")] + [InlineData("Name asc, Data[\"Score\"] desc")] // multi-column mixing plain + indexer + [InlineData("it[\"Score\"] desc")] // entity-level property-bag indexer (it["Prop"]) + public void Should_Accept_Constant_String_Indexer_Sorting(string sorting) + { + // Dynamically-mapped entities sort their shadow / property-bag members through a + // constant-string indexer; the guard must treat this as plain property access. + Should.NotThrow(() => FakeBagUsers().OrderBy(sorting).ToList()); + } + + [Theory] + [InlineData("Data[Name] desc")] // non-constant key (member access) + [InlineData("Data[PasswordHash.Substring(0,1)] desc")] // method call inside the key + public void Should_Reject_Non_Constant_Indexer_Sorting(string sorting) + { + Should.Throw(() => FakeBagUsers().OrderBy(sorting).ToList()) + .Message.ShouldBe("Sorting expression is not supported."); + } + + private static IQueryable FakeBagUsers() + { + return new List + { + new() { Name = "alice", PasswordHash = "AQAA", Data = { ["Score"] = 30 } }, + new() { Name = "bob", PasswordHash = "BQAA", Data = { ["Score"] = 25 } }, + new() { Name = "carl", PasswordHash = "CQAA", Data = { ["Score"] = 40 } }, + }.AsQueryable(); + } + private class FakeUser { public string Name { get; set; } = ""; @@ -274,4 +305,13 @@ public class AbpDynamicSortingGuard_Tests : AbpDddApplicationTestBase { public string Name { get; set; } = ""; } + + private class FakeBagUser + { + public string Name { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public Dictionary Data { get; set; } = new(); + + public object this[string key] => Data[key]; + } } diff --git a/schemas/low-code/definitions/dashboard-descriptor.schema.json b/schemas/low-code/definitions/dashboard-descriptor.schema.json index 068031a18b..0d4a55d038 100644 --- a/schemas/low-code/definitions/dashboard-descriptor.schema.json +++ b/schemas/low-code/definitions/dashboard-descriptor.schema.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "dashboard-descriptor.schema.json", "title": "DashboardDescriptor", - "description": "Describes a dashboard page configuration with global filters and rows of visualizations.", - "markdownDescription": "AI guidance: a dashboard belongs to a page with `type: \"dashboard\"`. It contains one or more rows; each row contains chart, list, or numberContainer visualizations. Use visualization `entityName` to select data for chart/list items. Use numberContainer for KPI tiles, charts for grouped aggregations, and lists for recent/top records.", + "description": "Describes a dashboard page configuration with global filters and a flat list of visualizations.", + "markdownDescription": "AI guidance: a dashboard belongs to a page with `type: \"dashboard\"`. It contains a flat `visualizations` list; each visualization carries its grid position via `row`, `order`, and `width`. Rows are derived at render time by grouping visualizations that share the same zero-based `row`. Use visualization `entityName` to select data for chart/list items. Use numberContainer for KPI tiles, charts for grouped aggregations, and lists for recent/top records.", "type": "object", "properties": { "description": { @@ -17,35 +17,34 @@ "$ref": "dashboard-global-filter-descriptor.schema.json" } }, - "rows": { + "visualizations": { "type": "array", - "description": "Dashboard rows. Each row contains one or more visualization items; width 2 items usually take the full row.", + "description": "Flat, name-keyed list of dashboard visualizations. Each visualization positions itself on the grid via row, order, and width.", "items": { - "$ref": "dashboard-row-descriptor.schema.json" + "$ref": "dashboard-visualization-descriptor.schema.json" }, "minItems": 1 } }, - "required": ["rows"], + "required": ["visualizations"], "additionalProperties": false, "examples": [ { "description": "Operational overview", - "rows": [ + "visualizations": [ { - "items": [ - { - "name": "events-by-status", - "type": "chart", - "title": "Events by Status", - "entityName": "Acme.Events.Event", - "chart": { - "chartType": "bar", - "xAxis": { "property": "Status" }, - "yAxis": [{ "aggregation": "count", "label": "Events" }] - } - } - ] + "name": "events-by-status", + "type": "chart", + "title": "Events by Status", + "row": 0, + "order": 0, + "width": 2, + "entityName": "Acme.Events.Event", + "chart": { + "chartType": "bar", + "xAxis": { "property": "Status" }, + "yAxis": [{ "aggregation": "count", "label": "Events" }] + } } ] } diff --git a/schemas/low-code/definitions/dashboard-row-descriptor.schema.json b/schemas/low-code/definitions/dashboard-row-descriptor.schema.json deleted file mode 100644 index 909048ec29..0000000000 --- a/schemas/low-code/definitions/dashboard-row-descriptor.schema.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "dashboard-row-descriptor.schema.json", - "title": "DashboardRowDescriptor", - "description": "Describes one row in the dashboard layout grid. Each row contains one or two visualization items.", - "markdownDescription": "AI guidance: organize dashboard visualizations into rows by visual importance. Use one item for a full-width chart/list/number panel, and two items when related visualizations should be displayed side by side. Do not put more than two visualizations in a row; create another row instead.", - "type": "object", - "properties": { - "items": { - "type": "array", - "description": "Visualization items in this row. Use one item for full-width content or two items for a two-column row.", - "items": { - "$ref": "dashboard-visualization-descriptor.schema.json" - }, - "minItems": 1, - "maxItems": 2 - } - }, - "required": ["items"], - "additionalProperties": false, - "examples": [ - { - "items": [ - { - "name": "overview", - "type": "numberContainer", - "title": "Overview", - "numberContainer": { - "items": [ - { "name": "total-records", "title": "Total Records", "entityName": "Acme.Events.Event", "aggregation": "count" } - ] - } - } - ] - } - ] -} diff --git a/schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json b/schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json index 6fd6e91f31..5634498f74 100644 --- a/schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json +++ b/schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "dashboard-visualization-descriptor.schema.json", "title": "DashboardVisualizationDescriptor", - "description": "A single visualization element in a dashboard row: chart, list, or number container.", + "description": "A single visualization element in a dashboard: chart, list, or number container. Positioned on the grid via row, order, and width.", "markdownDescription": "AI guidance: set `type` and then provide the matching payload: `chart` for type chart, `list` for type list, or `numberContainer` for type numberContainer. Do not populate unrelated payloads. Chart and list visualizations require `entityName`; number containers define entityName per KPI item.", "type": "object", "properties": { @@ -23,6 +23,16 @@ "type": ["string", "null"], "description": "Optional description text. Can be shown inline or as tooltip depending on showDescriptionAsTooltip." }, + "row": { + "type": "integer", + "description": "Zero-based visual row index inside the dashboard grid. Visualizations sharing the same row are rendered side-by-side.", + "minimum": 0, + "default": 0 + }, + "order": { + "type": ["integer", "null"], + "description": "Order of the visualization inside its row. When omitted, array order is used." + }, "width": { "type": "integer", "enum": [1, 2], @@ -146,6 +156,9 @@ "name": "events-by-status", "type": "chart", "title": "Events by Status", + "row": 0, + "order": 0, + "width": 1, "entityName": "Acme.Events.Event", "chart": { "chartType": "bar", @@ -157,6 +170,9 @@ "name": "recent-events", "type": "list", "title": "Recent Events", + "row": 0, + "order": 1, + "width": 1, "entityName": "Acme.Events.Event", "list": { "fields": ["Title", "StartDate", "Status"], diff --git a/schemas/low-code/definitions/form-descriptor.schema.json b/schemas/low-code/definitions/form-descriptor.schema.json index f8bec6dfc8..3508ff58a9 100644 --- a/schemas/low-code/definitions/form-descriptor.schema.json +++ b/schemas/low-code/definitions/form-descriptor.schema.json @@ -3,7 +3,7 @@ "$id": "form-descriptor.schema.json", "title": "FormDescriptor", "description": "Describes a named create/edit form definition bound to one entity.", - "markdownDescription": "AI guidance: a form is referenced by page `formName`, `createFormName`, or `editFormName`. Keep `entityName` aligned with the page entityName. Define all fields in `fields`, then place every visible field in `layout.tabs[].groups[].rows[].cells[]` by field id. Use form `rules` for conditional visibility/enabled state; use entity validators for core data validation.", + "markdownDescription": "AI guidance: a form is referenced by page `formName`, `createFormName`, or `editFormName`. Keep `entityName` aligned with the page entityName. Define all fields in `fields`, then place every visible field in `layout.tabs[].groups[].fields[]` by field id (each placement carries `row`, `colSpan`, and `colStart`). Use form `rules` for conditional visibility/enabled state; use entity validators for core data validation.", "type": "object", "properties": { "$schema": { @@ -65,9 +65,9 @@ "id": "details", "title": "Details", "isDefault": true, - "rows": [ - { "cells": [{ "fieldId": "title", "colSpan": 4 }] }, - { "cells": [{ "fieldId": "status", "colSpan": 2 }] } + "fields": [ + { "fieldId": "title", "row": 0, "colSpan": 4 }, + { "fieldId": "status", "row": 1, "colSpan": 2 } ] } ] diff --git a/schemas/low-code/definitions/form-field-descriptor.schema.json b/schemas/low-code/definitions/form-field-descriptor.schema.json index fd5f731e19..4875100078 100644 --- a/schemas/low-code/definitions/form-field-descriptor.schema.json +++ b/schemas/low-code/definitions/form-field-descriptor.schema.json @@ -3,12 +3,12 @@ "$id": "form-field-descriptor.schema.json", "title": "FormFieldDescriptor", "description": "Describes a single field in a form. A field may be bound to an entity property or unbound for computed/display-only UI.", - "markdownDescription": "AI guidance: use a stable camelCase `id` for each field. For ordinary data entry, set `binding` to an entity property and choose a field `type` compatible with that property. For enum selects, set `type: \"select\"` and `enumType`. For FK lookups, use `type: \"lookup\"` and bind to the FK property. Put fields into layout cells by id.", + "markdownDescription": "AI guidance: use a stable camelCase `id` for each field. For ordinary data entry, set `binding` to an entity property and choose a field `type` compatible with that property. For enum selects, set `type: \"select\"` and `enumType`. For FK lookups, use `type: \"lookup\"` and bind to the FK property. Place fields into the layout by adding a placement that references this id in a group's `fields`.", "type": "object", "properties": { "id": { "type": "string", - "description": "Unique identifier for this field within the form. Prefer camelCase, for example 'title' or 'customerId'. Layout cells and rules reference this id.", + "description": "Unique identifier for this field within the form. Prefer camelCase, for example 'title' or 'customerId'. Layout placements and rules reference this id.", "minLength": 1 }, "label": { diff --git a/schemas/low-code/definitions/form-layout-descriptor.schema.json b/schemas/low-code/definitions/form-layout-descriptor.schema.json index abadb86e21..364c0423dd 100644 --- a/schemas/low-code/definitions/form-layout-descriptor.schema.json +++ b/schemas/low-code/definitions/form-layout-descriptor.schema.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "form-layout-descriptor.schema.json", "title": "FormLayoutDescriptor", - "description": "Describes the visual layout of a form as tabs, groups, rows, and field cells.", - "markdownDescription": "AI guidance: the layout is a tree: tabs -> groups -> rows -> cells. Each cell `fieldId` must reference a field from the form's `fields` array. Use `colSpan` 4 for full-width fields, 2+2 for two columns, or 1+1+1+1 for four compact controls. The total effective width in a row should not exceed 4.", + "description": "Describes the visual layout of a form as tabs and groups. Each group holds a flat list of field placements positioned on a 4-column grid.", + "markdownDescription": "AI guidance: the layout is a tree: tabs -> groups -> fields. The stored schema is flat (no row/cell wrappers): each placement carries `row`, `colSpan`, and `colStart`. Rows are derived at render time by grouping placements that share the same zero-based `row`. Each placement `fieldId` must reference a field from the form's `fields` array. Use `colSpan` 4 for full-width fields, 2+2 for two columns (colStart 1 and 3), or 1+1+1+1 for four compact controls. The sum of `colSpan` values within a row should not exceed 4.", "type": "object", "properties": { "tabs": { @@ -28,6 +28,10 @@ "description": "Whether this is the default tab. The designer uses the default tab as the safe target for orphaned fields.", "default": false }, + "order": { + "type": "integer", + "description": "Optional display order for this tab. When omitted, array order is used." + }, "groups": { "type": "array", "description": "Ordered list of groups within this tab. Use one default group for simple forms.", @@ -49,49 +53,47 @@ "description": "Whether this is the default group. The designer uses the default group as the safe target for orphaned fields.", "default": false }, - "rows": { + "order": { + "type": "integer", + "description": "Optional display order for this group. When omitted, array order is used." + }, + "fields": { "type": "array", - "description": "Ordered list of layout rows; each row contains one or more cells placed side-by-side.", + "description": "Flat list of field placements in this group. Each placement positions a single field on the 4-column grid via row, colSpan, and colStart.", "items": { "type": "object", "properties": { - "cells": { - "type": "array", - "description": "Fields placed side-by-side in this row. The sum of colSpan values should not exceed 4.", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "fieldId": { - "type": "string", - "description": "Reference to a field id in the form's fields array. Every field shown in the layout needs a matching field descriptor.", - "minLength": 1 - }, - "colSpan": { - "type": "integer", - "description": "Number of grid columns this field spans from 1 to 4. Use 4 for full-width fields.", - "minimum": 1, - "maximum": 4, - "default": 4 - }, - "colStart": { - "type": ["integer", "null"], - "description": "Starting grid column from 1 to 4. Omit or null to auto-place after the previous cell.", - "minimum": 1, - "maximum": 4 - } - }, - "required": ["fieldId"], - "additionalProperties": false - } + "fieldId": { + "type": "string", + "description": "Reference to a field id in the form's fields array. Every field shown in the layout needs a matching field descriptor.", + "minLength": 1 + }, + "row": { + "type": "integer", + "description": "Zero-based visual row index inside the group. Placements sharing the same row are rendered side-by-side.", + "minimum": 0, + "default": 0 + }, + "colSpan": { + "type": "integer", + "description": "Number of grid columns this field spans from 1 to 4. Use 4 for full-width fields.", + "minimum": 1, + "maximum": 4, + "default": 4 + }, + "colStart": { + "type": ["integer", "null"], + "description": "Starting grid column from 1 to 4. Omit or null to auto-place after the previous placement in the same row.", + "minimum": 1, + "maximum": 4 } }, - "required": ["cells"], + "required": ["fieldId"], "additionalProperties": false } } }, - "required": ["id", "rows"], + "required": ["id", "fields"], "additionalProperties": false } } @@ -115,9 +117,10 @@ "id": "details", "title": "Details", "isDefault": true, - "rows": [ - { "cells": [{ "fieldId": "title", "colSpan": 4 }] }, - { "cells": [{ "fieldId": "startDate", "colSpan": 2 }, { "fieldId": "endDate", "colSpan": 2 }] } + "fields": [ + { "fieldId": "title", "row": 0, "colSpan": 4 }, + { "fieldId": "startDate", "row": 1, "colSpan": 2, "colStart": 1 }, + { "fieldId": "endDate", "row": 1, "colSpan": 2, "colStart": 3 } ] } ] diff --git a/schemas/low-code/definitions/page-descriptor.schema.json b/schemas/low-code/definitions/page-descriptor.schema.json index 3b5c1a2fcd..475de3ad63 100644 --- a/schemas/low-code/definitions/page-descriptor.schema.json +++ b/schemas/low-code/definitions/page-descriptor.schema.json @@ -237,21 +237,20 @@ "title": "Event Dashboard", "type": "dashboard", "dashboard": { - "rows": [ + "visualizations": [ { - "items": [ - { - "name": "events-by-status", - "type": "chart", - "title": "Events by Status", - "entityName": "Acme.Events.Event", - "chart": { - "chartType": "bar", - "xAxis": { "property": "Status" }, - "yAxis": [{ "aggregation": "count", "label": "Events" }] - } - } - ] + "name": "events-by-status", + "type": "chart", + "title": "Events by Status", + "row": 0, + "order": 0, + "width": 2, + "entityName": "Acme.Events.Event", + "chart": { + "chartType": "bar", + "xAxis": { "property": "Status" }, + "yAxis": [{ "aggregation": "count", "label": "Events" }] + } } ] } diff --git a/schemas/low-code/definitions/page-type.schema.json b/schemas/low-code/definitions/page-type.schema.json index 5ddeec0eb5..a3005eed50 100644 --- a/schemas/low-code/definitions/page-type.schema.json +++ b/schemas/low-code/definitions/page-type.schema.json @@ -3,7 +3,7 @@ "$id": "page-type.schema.json", "title": "PageType", "description": "The runtime page renderer to use. Prefer lower-case values in descriptor JSON; PascalCase aliases are accepted for compatibility.", - "markdownDescription": "`dataGrid` renders searchable/filterable tabular CRUD for an entity. `kanban` renders grouped cards and requires `groupByProperty`. `calendar` renders entity records on a calendar and requires `calendarStartProperty`. `gallery` renders visual cards and may use `galleryImageProperty`. `form` renders a standalone create/edit form page and requires `formName`. `dashboard` renders dashboard rows/visualizations and requires `dashboard`.", + "markdownDescription": "`dataGrid` renders searchable/filterable tabular CRUD for an entity. `kanban` renders grouped cards and requires `groupByProperty`. `calendar` renders entity records on a calendar and requires `calendarStartProperty`. `gallery` renders visual cards and may use `galleryImageProperty`. `form` renders a standalone create/edit form page and requires `formName`. `dashboard` renders a flat list of dashboard visualizations and requires `dashboard`.", "type": "string", "enum": [ "dataGrid",