Browse Source

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 <cursoragent@cursor.com>
pull/25637/head
SALİH ÖZKARA 3 months ago
parent
commit
64e9f1c2e1
  1. 29
      framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbpDynamicSortingGuard.cs
  2. 40
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/AbpDynamicSortingGuard_Tests.cs
  3. 39
      schemas/low-code/definitions/dashboard-descriptor.schema.json
  4. 37
      schemas/low-code/definitions/dashboard-row-descriptor.schema.json
  5. 18
      schemas/low-code/definitions/dashboard-visualization-descriptor.schema.json
  6. 8
      schemas/low-code/definitions/form-descriptor.schema.json
  7. 4
      schemas/low-code/definitions/form-field-descriptor.schema.json
  8. 79
      schemas/low-code/definitions/form-layout-descriptor.schema.json
  9. 27
      schemas/low-code/definitions/page-descriptor.schema.json
  10. 2
      schemas/low-code/definitions/page-type.schema.json

29
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 <see cref="AbpValidationException"/>.
/// Property-bag / shadow-property access through a constant string indexer
/// (e.g. <c>it["Prop"]</c>, <c>Data["Prop"]</c>) is treated as plain property access
/// and allowed, since it carries no side effects and is the canonical way to sort
/// dynamically-mapped entities.
/// </summary>
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 };
}
}
}

40
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<AbpValidationException>(() => FakeBagUsers().OrderBy(sorting).ToList())
.Message.ShouldBe("Sorting expression is not supported.");
}
private static IQueryable<FakeBagUser> FakeBagUsers()
{
return new List<FakeBagUser>
{
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<string, object> Data { get; set; } = new();
public object this[string key] => Data[key];
}
}

39
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" }]
}
}
]
}

37
schemas/low-code/definitions/dashboard-row-descriptor.schema.json

@ -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" }
]
}
}
]
}
]
}

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

8
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 }
]
}
]

4
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": {

79
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 }
]
}
]

27
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" }]
}
}
]
}

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

Loading…
Cancel
Save