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",