Browse Source

Merge pull request #25780 from abpframework/salihozkara/low-code-docs-1

Enhance and reorganize low-code documentation and navigation
pull/25808/head
SALİH ÖZKARA 4 days ago
committed by GitHub
parent
commit
e50dc1afd9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 70
      docs/en/docs-nav.json
  2. BIN
      docs/en/images/openiddict-client-credentials-application.png
  3. 287
      docs/en/low-code/code-integration.md
  4. 182
      docs/en/low-code/dashboards.md
  5. 41
      docs/en/low-code/designer.md
  6. 841
      docs/en/low-code/fluent-api.md
  7. 83
      docs/en/low-code/health.md
  8. BIN
      docs/en/low-code/images/designer-entity.png
  9. BIN
      docs/en/low-code/images/designer-forms.png
  10. BIN
      docs/en/low-code/images/designer-overview.png
  11. BIN
      docs/en/low-code/images/designer-page-filters.png
  12. BIN
      docs/en/low-code/images/designer-properties.png
  13. BIN
      docs/en/low-code/images/runtime-create-form.png
  14. BIN
      docs/en/low-code/images/runtime-dashboard.png
  15. BIN
      docs/en/low-code/images/runtime-data-grid.png
  16. BIN
      docs/en/low-code/images/runtime-filters-has-value.png
  17. BIN
      docs/en/low-code/images/runtime-filters.png
  18. BIN
      docs/en/low-code/images/studio-low-code-step.png
  19. 53
      docs/en/low-code/index.md
  20. 158
      docs/en/low-code/mcp.md
  21. 199
      docs/en/low-code/model-json.md
  22. 123
      docs/en/low-code/page-groups.md
  23. 17
      docs/en/low-code/react-runtime.md
  24. 3
      docs/en/low-code/reference-entities.md
  25. 4
      docs/en/low-code/script-actions.md
  26. 82
      docs/en/low-code/scripting-api.md
  27. 38
      docs/en/modules/openiddict-pro.md

70
docs/en/docs-nav.json

@ -2101,6 +2101,76 @@
}
]
},
{
"text": "Low-Code System",
"items": [
{
"text": "Overview",
"path": "low-code",
"isIndex": true
},
{
"text": "Low-Code Designer",
"path": "low-code/designer.md"
},
{
"text": "React Runtime",
"path": "low-code/react-runtime.md"
},
{
"text": "Health",
"path": "low-code/health.md"
},
{
"text": "Dashboards",
"path": "low-code/dashboards.md"
},
{
"text": "Page Groups",
"path": "low-code/page-groups.md"
},
{
"text": "MCP Integration",
"path": "low-code/mcp.md"
},
{
"text": "Attributes & Fluent API",
"path": "low-code/fluent-api.md"
},
{
"text": "Model Descriptor Files",
"path": "low-code/model-json.md"
},
{
"text": "Reference Entities",
"path": "low-code/reference-entities.md"
},
{
"text": "Code Integration",
"path": "low-code/code-integration.md"
},
{
"text": "Foreign Access",
"path": "low-code/foreign-access.md"
},
{
"text": "Interceptors",
"path": "low-code/interceptors.md"
},
{
"text": "Custom Endpoints",
"path": "low-code/custom-endpoints.md"
},
{
"text": "Script Actions",
"path": "low-code/script-actions.md"
},
{
"text": "Scripting API",
"path": "low-code/scripting-api.md"
}
]
},
{
"text": "Solution Templates",
"items": [

BIN
docs/en/images/openiddict-client-credentials-application.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

287
docs/en/low-code/code-integration.md

@ -0,0 +1,287 @@
```json
//[doc-seo]
{
"Description": "Connect ABP Low-Code entities with regular C# code. Register reference entities for low-code lookups and access low-code entities from application code via typed repositories, DynamicEntity, or IDynamicPageAppService."
}
```
# Code Integration
> **Preview:** Low-Code integration APIs are part of the preview Low-Code System. Registration details, repository behavior, and page service contracts may change before general availability.
Use the [Low-Code Designer](designer.md) and [React Runtime](react-runtime.md) for standard CRUD screens. This page covers the integration points for the cases where low-code models and regular application code need to reach each other directly.
## Choose the Right Integration Path
| Need | Recommended approach |
|------|----------------------|
| A low-code field should point to an existing C# entity such as `IdentityUser` | Register a [reference entity](reference-entities.md) and use its CLR full name in the foreign key definition |
| Application code should work with a low-code entity that has a real C# class | Inject `IRepository<TEntity, Guid>` for that `[DynamicEntity]` class |
| Application code should work with a descriptor-only or runtime-defined low-code entity | Inject `IRepository<DynamicEntity, Guid>` and call `SetEntityName(...)` |
| Application code should reuse runtime page behavior such as low-code permissions, foreign-access checks, files, attachments, or child-page rules | Inject `IDynamicPageAppService` and work by `pageName` |
## Low-Code to Existing C# Entities
Low-code entities can point to existing entities that are not managed by the Low-Code System itself. Register them once during startup:
````csharp
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>(
"UserName",
"UserName",
"Email"
);
````
Then use the registered CLR type name in a foreign key:
````csharp
[DynamicForeignKey("Volo.Abp.Identity.IdentityUser", "UserName")]
public Guid? OwnerId { get; set; }
````
or in a descriptor file:
```json
{
"name": "OwnerId",
"foreignKey": {
"entityName": "Volo.Abp.Identity.IdentityUser"
}
}
```
Scripts can also query the same reference entity:
```javascript
var user = await db.get('Volo.Abp.Identity.IdentityUser', ownerId);
```
Reference entities are read-only from the low-code side. For the full registration model and metadata details, see [Reference Entities](reference-entities.md).
## C# Code to Low-Code Entities
### Option 1: Typed Repository for Code-Defined Dynamic Entities
If the low-code entity is declared as a normal C# class with `[DynamicEntity]` and `DynamicEntityBase`, use it like any other ABP entity. The low-code demo's `Customer` entity is a typical example:
````csharp
public class CustomerSyncService : ITransientDependency
{
private readonly IRepository<Customer, Guid> _customerRepository;
public CustomerSyncService(IRepository<Customer, Guid> customerRepository)
{
_customerRepository = customerRepository;
}
public async Task UpdateAsync(Guid id)
{
var customer = await _customerRepository.GetAsync(id);
customer.SetData("Telephone", "5550001122");
customer.SetData("CreditLimit", 119.99m);
await _customerRepository.UpdateAsync(customer);
}
}
````
This is the simplest path when a CLR type already exists. `GetData(...)` and `SetData(...)` still work for dynamic or extra properties on that typed entity.
### Option 2: Generic Repository for Descriptor-Only or Runtime-Defined Entities
If the low-code entity only exists in descriptor files or runtime metadata, inject `IRepository<DynamicEntity, Guid>`. Set the entity name before repository operations:
````csharp
public class ProductImportService : ITransientDependency
{
private readonly IRepository<DynamicEntity, Guid> _dynamicEntityRepository;
public ProductImportService(IRepository<DynamicEntity, Guid> dynamicEntityRepository)
{
_dynamicEntityRepository = dynamicEntityRepository;
}
public async Task<Guid> CreateAsync()
{
_dynamicEntityRepository.SetEntityName("LowCodeDemo.Products.Product");
var product = new DynamicEntity("LowCodeDemo.Products.Product", Guid.NewGuid())
{
["Name"] = "Road Helmet",
Data =
{
["Price"] = 125m,
["StockCount"] = 40
}
};
await _dynamicEntityRepository.InsertAsync(product);
return product.Id;
}
public async Task<decimal> GetPriceAsync(Guid id)
{
_dynamicEntityRepository.SetEntityName("LowCodeDemo.Products.Product");
var product = await _dynamicEntityRepository.GetAsync(id);
return product.GetData<decimal>("Price");
}
}
````
Call `SetEntityName(...)` again whenever you switch to another low-code entity name. After that, standard repository operations such as `GetAsync`, `GetListAsync`, `InsertAsync`, `UpdateAsync`, `DeleteAsync`, and `GetQueryableAsync` work normally.
Use `GetData(...)` and `SetData(...)` for normal read/write code. Use the indexers only when you need to target the underlying storage explicitly, such as query expressions or manual `DynamicEntity` construction.
| API | Use it for |
|-----|------------|
| `entity.GetData<T>("FieldName")` | General read API; it resolves CLR properties, mapped fields, and extra properties through one call |
| `entity.SetData("FieldName", value)` | General write API; it chooses the mapped field or `Data` dictionary automatically |
| `entity["FieldName"]` | Direct access to a dynamic property with `isMappedToDbField: true` |
| `entity.Data["FieldName"]` | Direct access to a dynamic property without `isMappedToDbField: true` |
### Query Expressions: `entity["Field"]` vs `entity.Data["Field"]`
For materialized entities, `GetData(...)` is the safest read API because it hides the storage details. For `IQueryable` expressions, use the access form that matches the property's storage shape so EF Core and the low-code query layer can translate it correctly:
| Property shape | Query form |
|----------------|------------|
| Normal CLR property | `x.PropertyName` |
| Dynamic property with `isMappedToDbField: true` | `x["PropertyName"]` |
| Dynamic property without `isMappedToDbField: true` | `x.Data["PropertyName"]` |
The low-code demo uses both patterns in the same query. In `LowCodeDemo.Orders.OrderLine`, `ProductId` is mapped to a DB field, while `Amount` stays in the `Data` dictionary:
````csharp
var orderLineQuery = await _dynamicEntityRepository
.SetEntityName("LowCodeDemo.Orders.OrderLine")
.GetQueryableAsync();
var filtered = orderLineQuery
.Where(line => line.Data["Amount"] != null && (decimal?)line.Data["Amount"] > 1000m)
.Select(line => new
{
ProductId = (Guid?)line["ProductId"],
Amount = (decimal?)line.Data["Amount"]
});
````
The same rule applies to joins. `ProductId` is joined through the entity indexer because it is mapped, while `CustomerId` on `LowCodeDemo.Orders.Order` is joined through `Data` because it is not:
````csharp
var orderLineQuery = await _dynamicEntityRepository
.SetEntityName("LowCodeDemo.Orders.OrderLine")
.GetQueryableAsync();
var orderQuery = await _dynamicEntityRepository
.SetEntityName("LowCodeDemo.Orders.Order")
.GetQueryableAsync();
var query =
from orderLine in orderLineQuery
join order in orderQuery
on (Guid?)orderLine["OrderId"] equals order.Id
select new
{
OrderId = order.Id,
CustomerId = (Guid?)order.Data["CustomerId"],
Amount = (decimal?)orderLine.Data["Amount"]
};
````
Use `GetData(...)` again after the query has been materialized into entity objects.
### Option 3: `IDynamicPageAppService` for Runtime-Like Behavior
Use `IDynamicPageAppService` when custom code should go through the same page-level behavior as the generated runtime: low-code permissions, foreign-access validation, lookup rules, file handling, attachments, export, and child records.
This API works with `pageName`, not `entityName`:
````csharp
public class ProductRuntimeService : ITransientDependency
{
private readonly IDynamicPageAppService _dynamicPageAppService;
public ProductRuntimeService(IDynamicPageAppService dynamicPageAppService)
{
_dynamicPageAppService = dynamicPageAppService;
}
public async Task<DynamicEntityDto> CreateAsync()
{
return await _dynamicPageAppService.CreateAsync(
"products",
new DynamicEntityCreateInput
{
Properties = new Dictionary<string, string?>
{
["Name"] = "Road Helmet",
["Price"] = "125",
["StockCount"] = "40"
}
}
);
}
public async Task<PagedResultDto<DynamicEntityDto>> GetListAsync()
{
return await _dynamicPageAppService.GetDataAsync(
"products",
new DynamicEntityListRequestDto
{
MaxResultCount = 20
}
);
}
}
````
Reach for this service when your custom code should behave like the runtime page rather than like a raw repository call.
## Joining Low-Code and Regular Entities in LINQ
Low-code entities can be composed with typed repositories in the same LINQ query. The low-code demo does this by joining dynamic order lines and orders with the typed `Customer` repository:
````csharp
var orderLineQuery = await _dynamicEntityRepository
.SetEntityName("LowCodeDemo.Orders.OrderLine")
.GetQueryableAsync();
var orderQuery = (await _dynamicEntityRepository
.SetEntityName("LowCodeDemo.Orders.Order")
.GetQueryableAsync())
.As<IQueryable<DynamicEntity>>();
var customerQuery = (await _customerRepository.GetQueryableAsync())
.As<IQueryable<IEntity<Guid>>>();
var query = orderLineQuery
.Where(line => line.Data["Amount"] != null && (decimal?)line.Data["Amount"] > 1000m)
.GroupJoin(
orderQuery,
orderLine => (Guid?)orderLine["OrderId"],
order => order.Id,
(orderLine, orders) => new { orderLine, orders }
)
.SelectMany(
x => x.orders.DefaultIfEmpty(),
(x, order) => new { x.orderLine, order }
)
.GroupJoin(
customerQuery,
x => (Guid?)x.order!.Data["CustomerId"],
customer => customer.Id,
(x, customers) => new { x.orderLine, x.order, customers }
);
````
This is useful when a business rule or reporting service needs both low-code entities and typed C# entities in one application-layer query.
## See Also
* [Reference Entities](reference-entities.md)
* [Attributes & Fluent API](fluent-api.md)
* [Model Descriptor Files](model-json.md)
* [React Runtime](react-runtime.md)
* [Scripting API](scripting-api.md)

182
docs/en/low-code/dashboards.md

@ -0,0 +1,182 @@
```json
//[doc-seo]
{
"Description": "Define ABP Low-Code dashboard pages, visualizations, filters, layout, permissions, and React runtime data flow."
}
```
# Dashboards
Dashboards are low-code pages that render charts, lists, and number widgets from low-code entity data. A dashboard is still a page, so it uses the normal page name, title, icon, order, group, and permission model, but its page type is `dashboard` and its page definition carries a nested `dashboard` payload. The screenshot below shows an inventory-style dashboard from the demo app, and the JSON that follows is a simplified descriptor that uses the same layout concepts.
The runtime below was generated from a low-code dashboard page definition:
![Generated React dashboard page](images/runtime-dashboard.png)
## Dashboard Pages
Dashboard pages live in the normal page descriptor collection. In source-controlled models, a dashboard page still belongs under `pages/`, not a separate dashboard folder.
Typical page-level fields include:
* `name`
* `title`
* `icon`
* `type: "dashboard"`
* `group`
* `order`
* `permissionConfig`
* `dashboard`
Runtime routes use the same dynamic page convention:
```text
/dynamic/<page-name>
```
## Layout Model
The stored dashboard descriptor uses a **flat visualization list**. Each visualization defines its own placement:
* `row`: zero-based visual row index
* `order`: order within the row
* `width`: current dashboard grid width, typically `1` or `2`
At runtime, the React UI groups those flat visualizations into rendered rows. This is similar to form layouts: storage stays flat, rendering derives the grouped structure.
```json
{
"name": "inventory-overview",
"title": "Inventory Overview",
"type": "dashboard",
"group": "inventory",
"dashboard": {
"description": "Operational inventory view",
"visualizations": [
{
"name": "product-count",
"type": "numberContainer",
"title": "Product Count",
"row": 0,
"order": 0,
"width": 1,
"numberContainer": {
"items": [
{
"name": "total-products",
"title": "Total Products",
"entityName": "Acme.Catalog.Product",
"aggregation": "count",
"format": "number"
}
]
}
},
{
"name": "stock-by-product",
"type": "chart",
"title": "Stock by Product",
"row": 0,
"order": 1,
"width": 1,
"entityName": "Acme.Catalog.Product",
"chart": {
"chartType": "bar",
"xAxis": { "property": "Name" },
"yAxis": [
{ "aggregation": "sum", "property": "StockCount", "label": "Stock" }
]
}
}
]
}
}
```
## Visualization Types
The current dashboard visualization types are:
* `chart`
* `list`
* `numberContainer`
### Chart
Chart visualizations define:
* `chartType`: `bar`, `line`, `pie`, or `donut`
* `xAxis`
* one or more `yAxis` aggregation series
* optional `maxItems`
* optional `showRecordCount`
* optional bar orientation
### List
List visualizations define:
* `fields`
* optional `sortBy`
* `maxRows`
* `rowHeight`
* optional `colorBy`
### Number Container
Number containers hold one or more number items. Each item can define:
* `aggregation`
* `aggregationProperty`
* `format`
* `color`
* entity-specific filters and global date filter linkage
* click-through behavior
The sample descriptor above combines a number container and a chart in the same `Inventory Overview` page.
## Filters and Interactivity
Dashboards support three filter layers:
* `globalFilters` for page-level controls such as date range
* visualization-level `filter` for fixed query constraints
* visualization `userFilters` for interactive filtering exposed to the runtime user
Other useful dashboard interaction fields include:
* `globalDateFilterProperty`
* `showDescriptionAsTooltip`
* `clickToSeeRecords`
These options let a dashboard stay compact while still allowing drill-down behavior in the runtime.
## Runtime Shape
The runtime definition exposed to React is grouped by rows and items, even though the stored descriptor is flat. The React runtime uses:
* `useDashboardDefinition`
* `useDashboardData`
* `GET /api/low-code/ui/dashboards/{pageName}`
* `POST /api/low-code/dashboards/{pageName}/data`
See [React Runtime](react-runtime.md) for hook-level details and route integration.
## Permissions and Menu Placement
Dashboard pages are read-oriented. Generated dashboard page operations are **view only**, so the usual CRUD operation set does not apply.
Dashboard pages can still:
* appear inside a [Page Group](page-groups.md)
* carry a menu icon and order
* use explicit or generated page permission configuration
Menu placement is handled at the page level through the normal `group` and `order` fields.
## See Also
* [React Runtime](react-runtime.md)
* [Low-Code Designer](designer.md)
* [Page Groups](page-groups.md)
* [Model Descriptor Files](model-json.md)

41
docs/en/low-code/designer.md

@ -58,6 +58,8 @@ For reference entities such as `IdentityUser`, register the entity in the genera
Use **Pages** to expose an entity in the React runtime.
Use [Page Groups](page-groups.md) to organize runtime menu folders and [Dashboards](dashboards.md) when a page type needs dashboard-specific descriptor details.
Pages can define data grid, kanban, calendar, gallery, standalone form, and dashboard experiences. A data grid page can define:
* Title and icon
@ -98,6 +100,37 @@ Forms can contain:
* Conditional rules for hide/show, enable/disable, and set value behavior
* Save actions such as "save and new"
Source-controlled forms still need valid layout placements. If a form shows `No fields in this group`, check the matching descriptor file and make sure the group uses `layout.tabs[].groups[].fields[]` placements whose `fieldId` values match the form field definitions.
For example, this form defines three fields and places those same field IDs into the group layout:
```json
{
"fields": [
{ "id": "name", "label": "Name", "type": "text", "binding": "Name" },
{ "id": "price", "label": "Price", "type": "money", "binding": "Price" },
{ "id": "stock-count", "label": "Stock Count", "type": "number", "binding": "StockCount" }
],
"layout": {
"tabs": [
{
"groups": [
{
"fields": [
{ "fieldId": "name", "row": 0, "colSpan": 4 },
{ "fieldId": "price", "row": 1, "colSpan": 2 },
{ "fieldId": "stock-count", "row": 1, "colSpan": 2 }
]
}
]
}
]
}
}
```
The designer and runtime read the flat `fields[]` placement list shown above. If placements are missing, or if a placement points to a different ID such as `isActive` instead of `is-active`, the form can load but the group may stay visually empty.
![Form setup](images/designer-forms.png)
## Filters
@ -128,7 +161,13 @@ Endpoint and event handler editors include **Test JavaScript**. Dry-run executio
## Health
Use **Health** before shipping changes. It helps catch missing display properties, invalid relation targets, form/page references, script problems, and other model issues that would otherwise surface at runtime.
Use **Health** before shipping changes. It helps catch missing display properties, invalid relation targets, form/page references, script problems, and other model issues that would otherwise surface at runtime. See [Health](health.md) for the selected-layer snapshot scope and the typical problem classes it helps you review.
## MCP Integration
The Designer and the low-code MCP surface overlap when the selected layer is **Runtime JSON**, but they are not the same editing surface. The Designer can inspect source-controlled and runtime layers, while [MCP Integration](mcp.md) is a remote HTTP MCP endpoint that is intentionally runtime-only and targets the database-backed model. Use the Designer when you want interactive editing and visual feedback. Use MCP when an authenticated agent or script needs repeatable runtime automation.
After MCP-driven changes, reopen the relevant Designer section or review [Health](health.md) before reporting the model as ready.
## Source Control

841
docs/en/low-code/fluent-api.md

File diff suppressed because it is too large

83
docs/en/low-code/health.md

@ -0,0 +1,83 @@
```json
//[doc-seo]
{
"Description": "Review the ABP Low-Code model health snapshot to inspect entities, pages, forms, page groups, permissions, and script assets before publishing runtime changes."
}
```
# Health
The **Health** section in the Low-Code Designer is a selected-layer readiness view. It helps you review the resolved low-code model for the currently selected layer before publishing changes or relying on the generated runtime pages.
Health works on the **resolved view of the selected layer**, not on raw JSON files. In practice, this means it shows the selected layer after lower-layer inheritance and descriptor materialization have been applied. It does not silently merge unrelated edits from another layer. To review runtime-only overrides, switch the Designer to **Runtime JSON** or use the runtime-focused [MCP Integration](mcp.md) workflow.
## What Health Reads
The health snapshot is a combined view of the current low-code model in the selected layer. It includes:
* Entities
* Enums
* Page groups
* Pages
* Forms
* Custom permissions
* Effective page permission configuration
* Custom endpoints
* Script event handlers
* Script background jobs
* Script background workers
This makes Health the best place to review cross-descriptor relationships instead of checking each designer tab in isolation.
## What It Helps You Catch
Health is most useful for selected-layer consistency problems that often appear only after several descriptors start referencing each other.
Typical problem classes include:
* Page to form mismatches, where a page references a form that does not exist or belongs to another entity
* Page-type configuration errors, such as a kanban page missing `groupByProperty`, a calendar page using invalid date or time fields, or a gallery page pointing to a non-image property
* Page-group structure problems such as missing parents, circular references, or nesting deeper than the supported depth
* Form layout issues where fields exist but valid placements do not
* Permission and page configuration mismatches that would affect runtime visibility or access
* Script assets that exist in the model but still need review in the broader context of entities, pages, and permissions
Some of these issues are also enforced by runtime validation and mutation rules. Health gives you a selected-layer review point before users discover the problem in the runtime UI.
## Use It When
Use Health in these moments:
* After changing entities, pages, forms, page groups, or permissions in the Designer
* After applying MCP-driven runtime mutations
* Before publishing a set of runtime changes
* After copying or importing source-controlled descriptors into an application
If you automate low-code changes through [MCP Integration](mcp.md), re-read the health snapshot after apply and treat that review as part of the success criteria.
## Health and Source-Controlled Models
Health is not a replacement for source-controlled file validation.
When you edit `_Dynamic/model/**/*.json` directly:
1. Run the generated model file checker:
```bash
dotnet run --project <startup-project> -- --check-lowcode-model-files
```
2. Run the required migration workflow for schema-affecting entity changes.
3. Open the Designer and review **Health** after the model has loaded successfully.
The file checker catches JSON and category-level problems. Health complements it by helping you inspect the resolved model after descriptors, references, permissions, pages, and forms are all combined.
A useful example is form layout drift: a descriptor can still pass `--check-lowcode-model-files` while a source-controlled form renders with no visible fields because its layout placements are stale. Health and runtime preview are the places to catch that class of issue.
## See Also
* [Low-Code Designer](designer.md)
* [MCP Integration](mcp.md)
* [Model Descriptor Files](model-json.md)
* [Dashboards](dashboards.md)
* [Page Groups](page-groups.md)

BIN
docs/en/low-code/images/designer-entity.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
docs/en/low-code/images/designer-forms.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 36 KiB

BIN
docs/en/low-code/images/designer-overview.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
docs/en/low-code/images/designer-page-filters.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 50 KiB

BIN
docs/en/low-code/images/designer-properties.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/en/low-code/images/runtime-create-form.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 56 KiB

BIN
docs/en/low-code/images/runtime-dashboard.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
docs/en/low-code/images/runtime-data-grid.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 39 KiB

BIN
docs/en/low-code/images/runtime-filters-has-value.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 78 KiB

BIN
docs/en/low-code/images/runtime-filters.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 80 KiB

BIN
docs/en/low-code/images/studio-low-code-step.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

53
docs/en/low-code/index.md

@ -34,9 +34,13 @@ Low-Code runtime UI is currently documented for **React**. The backend model, AP
## How to Enable
The Low-Code System is an optional startup template feature. When creating a new application with [ABP Studio](../studio/index.md), choose a modern React application template and enable **Low-Code System** in the project creation wizard.
The Low-Code System is an optional [ABP Studio](../studio/index.md) feature for modern application templates. In the modern wizard, the **Low-Code System** step is available for layered, single-layer, and modular monolith solutions. The step is skipped for microservice architecture and disabled when MongoDB is selected because runtime-managed tables require EF Core.
ABP Studio creates the required backend module references, dynamic model initializer, EF Core configuration, Admin Console integration, and React runtime wiring.
![ABP Studio Low-Code step](images/studio-low-code-step.png)
Enable **Include Low-Code runtime and designer** to add the low-code runtime, designer APIs, EF Core integration, and, when a React web UI is included, the generated React runtime wiring and dynamic routes.
ABP Studio creates the required backend module references, dynamic model initializer, EF Core configuration, and Admin Console integration. When the solution also includes the React web application, Studio adds the `@volo/abp-react-lowcode` package, dynamic routes, and menu integration for `/dynamic/...` runtime pages.
The generated React project includes:
@ -49,6 +53,15 @@ The generated React project includes:
The host application wires the low-code modules, calls the generated `_Dynamic` initializer, configures EF Core dynamic entities, and seeds the required OpenIddict clients.
Generated solutions keep source-controlled low-code assets under `_Dynamic`:
* Layered and modular monolith solutions: `src/<YourApp>.Domain/_Dynamic/`
* Single-layer solutions: `<YourApp>.Host/_Dynamic/`
The `_Dynamic` folder includes the generated initializer, a `model/` directory that the runtime scans, and a `model-examples/` directory with sample descriptors that stay inactive until copied into `model/`.
Copying files from `model-examples/` into `model/` activates the descriptors, but persisted entity changes still follow the normal source-controlled migration flow. Run the generated database or low-code migration task before expecting `/dynamic/...` pages or `/api/low-code/pages/.../data` endpoints to read and write the backing table safely.
## Run the Application
After ABP Studio creates the solution, use **Solution Runner** to run the backend host and the React application. Run the database migration task before opening the runtime pages.
@ -69,6 +82,16 @@ Open generated runtime pages after the React application is running:
http://localhost:<react-port>/dynamic/<page-name>
```
If a copied sample page returns `403`, grant the generated low-code permissions to the role or user you are testing with through the standard ABP permission management UI.
If you edit descriptor JSON files manually, validate them before running migrations or publishing changes:
```bash
dotnet run --project <startup-project> -- --check-lowcode-model-files
```
The generated startup project accepts `--model-directory <path-to-_Dynamic/model>` when you want to validate a specific folder instead of the configured source assemblies.
## Designer Workflow
The designer is the day-to-day entry point.
@ -80,33 +103,37 @@ The designer is the day-to-day entry point.
5. Use **Actions** and **Interceptors** when the standard CRUD flow needs custom logic, endpoints, event handlers, jobs, or workers.
6. Use **Health** to review model issues before publishing changes.
The screens below follow that common designer flow from data to page setup to forms:
![Entity properties in the designer](images/designer-properties.png)
![Page setup in the designer](images/designer-page-filters.png)
![Form setup in the designer](images/designer-forms.png)
## React Runtime
React runtime pages are generated from the same metadata. The page below was produced from a low-code page definition and includes the grid, menu item, permissions, display values, export, create form, and filters. The same runtime can render kanban, calendar, gallery, standalone form, and dashboard page definitions.
React runtime pages are generated from the same metadata. The examples below show a generated data grid and its create form. The same runtime can render kanban, calendar, gallery, standalone form, and dashboard page definitions.
![Generated React data grid](images/runtime-data-grid.png)
![Generated React advanced filters](images/runtime-filters.png)
![Generated React create form](images/runtime-create-form.png)
## Filters
React low-code filters are type-aware. The runtime shows only operators that make sense for the field type. For example:
![Generated React advanced filters](images/runtime-filters.png)
* Text fields support contains, equals, starts with, ends with, and has value.
* Numeric fields support equals, comparison, between, and has value.
* Date fields use date-friendly labels such as on, after, before, and between.
* Boolean fields use an `All / Yes / No` value selector.
* File and image fields use `Has value` with an `All / Yes / No` value selector.
* File and image fields use the same `All / Yes / No` selector behind a `Has value` filter.
`All` means no filter is applied. `Yes` maps to non-empty values. `No` maps to empty values.
In the current screenshot set, the dropdown below is shown on the `Active` boolean filter. The same three-value selector is also used by `Has value` filters on file and image fields.
![Has value filter options](images/runtime-filters-has-value.png)
![Yes/No filter options](images/runtime-filters-has-value.png)
## Export
@ -146,9 +173,14 @@ The designer stores and reads the same descriptor metadata described in the refe
|-------|------------|
| [Designer](designer.md) | Admin Console tabs, entity/page/form setup, permissions, and health |
| [React Runtime](react-runtime.md) | React package wiring, routes, menu items, filters, forms, and export |
| [Health](health.md) | Selected-layer readiness review across entities, pages, forms, page groups, permissions, and scripts |
| [Dashboards](dashboards.md) | Dashboard page descriptors, visualization layout, filters, and runtime data flow |
| [Page Groups](page-groups.md) | Dynamic menu folders, nesting, ordering, and page grouping |
| [MCP Integration](mcp.md) | Remote MCP endpoint, OpenIddict token setup, and authenticated runtime automation |
| [Attributes & Fluent API](fluent-api.md) | Source-controlled C# metadata and runtime overrides |
| [Model Descriptor Files](model-json.md) | JSON descriptor files and public descriptor schemas used by the designer and runtime |
| [Reference Entities](reference-entities.md) | Lookups to existing entities such as Identity users |
| [Code Integration](code-integration.md) | Moving between low-code models and regular C# code in both directions |
| [Foreign Access](foreign-access.md) | Access to related dynamic entities through relations |
| [Interceptors](interceptors.md) | JavaScript lifecycle logic for CRUD operations |
| [Custom Endpoints](custom-endpoints.md) | JavaScript-backed REST endpoints |
@ -170,4 +202,9 @@ The generated pages are powered by these services:
* [Low-Code Designer](designer.md)
* [React Runtime](react-runtime.md)
* [Health](health.md)
* [Dashboards](dashboards.md)
* [Page Groups](page-groups.md)
* [MCP Integration](mcp.md)
* [Code Integration](code-integration.md)
* [Model Descriptor Files](model-json.md)

158
docs/en/low-code/mcp.md

@ -0,0 +1,158 @@
```json
//[doc-seo]
{
"Description": "Connect remote MCP clients to the ABP Low-Code Designer MCP endpoint with OpenIddict client credentials tokens."
}
```
# MCP Integration
> **Preview:** The low-code MCP surface is part of the preview Low-Code System. Endpoint behavior, authorization requirements, and mutation metadata may change before general availability.
The Low-Code Designer exposes a **remote Model Context Protocol (MCP) server** from the running ABP HTTP API host. It is not a local `stdio` tool package. MCP clients connect to your application over HTTP, authenticate with an OpenIddict access token, and work against the same **runtime database-backed** model that the Designer edits in the **Runtime JSON** layer.
Use [Model Descriptor Files](model-json.md) when you need source-controlled `_Dynamic/model/**/*.json` files. Use MCP when an agent or automation needs authenticated, structured runtime access that the Designer can immediately show.
## Remote Endpoint
The low-code MCP endpoint is hosted by the backend application that includes the Low-Code Designer HTTP API module:
```text
https://localhost:<host-port>/mcp/low-code-designer
```
Connection details:
* **Transport:** Streamable HTTP / HTTP MCP transport
* **Authentication:** `Authorization: Bearer <access-token>`
* **Server name:** `abp-low-code-runtime-designer`
* **Scope:** Runtime database-backed low-code model only
Use the backend host URL, not the React runtime URL and not the Admin Console SPA URL. In tiered solutions, connect to the HTTP API host where the low-code designer API module is loaded.
## Required Permissions
The endpoint requires the Low-Code Designer permission group:
| Permission | Required for |
|------------|--------------|
| `AbpLowCodeDesigner.Default` | Connecting and reading runtime model metadata |
| `AbpLowCodeDesigner.Edit` | Validating and applying runtime model changes |
| `AbpLowCodeDesigner.ScriptTest` | Running script dry-runs through MCP |
Grant these permissions to the **OpenIddict application/client** that will request the token, not only to a user or role. In Admin Console, use the permission action on the OpenIddict application row and grant the required Low-Code Designer permissions for the client.
See the [Permission Management Module](../modules/permission-management.md#permission-management-dialog) for the standard permission dialog behavior.
## Create an Access Token
For unattended MCP automation, create a dedicated OpenIddict application instead of reusing an interactive user token.
1. Open **Admin Console**.
2. Go to **OpenIddict** > **Applications**.
3. Create a new application, for example `LowCodeMcpClient`.
4. Set **Client type** to `Confidential client`.
5. Set a strong **Client secret** and store it securely.
6. Open the **Authorization** tab and enable **Allow client credentials flow**.
7. Open the **Scopes** tab and allow the API scope used by the backend that hosts the low-code APIs, for example `<YourAppName>`.
8. Save the application.
9. From the application row, open **Permissions** and grant the Low-Code Designer permissions required by your MCP scenario.
The OpenIddict application and scope management screens are described in [Creating a Client Credentials Application](../modules/openiddict-pro.md#creating-a-client-credentials-application).
The Admin Console application screen should show the confidential client with **Allow client credentials flow** enabled:
![openiddict-client-credentials-application](../images/openiddict-client-credentials-application.png)
Request an access token from the OpenIddict authority with the saved client id, client secret, and selected API scope. In non-tiered solutions this is usually the same backend host; in tiered solutions it is usually the Auth Server URL:
```bash
curl -X POST "https://localhost:<auth-server-port>/connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=LowCodeMcpClient" \
-d "client_secret=<client-secret>" \
-d "scope=<YourAppName>"
```
Use the returned `access_token` as the bearer token in the MCP client configuration.
```text
Authorization: Bearer <access-token>
```
## Connect From An MCP Client
For ABP AI Management or another MCP client that supports remote HTTP servers, create a server entry with:
| Setting | Value |
|---------|-------|
| Name | `ABP Low-Code Designer` |
| Transport | HTTP / Streamable HTTP |
| Endpoint URL | `https://localhost:<host-port>/mcp/low-code-designer` |
| Authentication | Bearer token |
| Token | The OpenIddict access token created for `LowCodeMcpClient` |
For clients that use JSON configuration, the same connection usually looks like this:
```json
{
"mcpServers": {
"abp-low-code-designer": {
"type": "streamable-http",
"url": "https://localhost:<host-port>/mcp/low-code-designer",
"headers": {
"Authorization": "Bearer <access-token>"
}
}
}
}
```
Client configuration names differ by product. If your client uses `http`, `streamableHttp`, or a separate `authorization` field, keep the same endpoint URL and bearer token.
## Runtime-Only Scope
The MCP surface is **runtime-only**:
* It always reads and writes the database-backed runtime model.
* It does not choose between source-controlled and runtime layers at call time.
* It does not edit source-controlled `_Dynamic/model/**/*.json` descriptor files.
* It does not replace the normal migration workflow for source-controlled model changes.
Runtime MCP writes can extend descriptors that originate from lower layers, but inherited storage details remain restricted. For example, runtime edits can typically change display labels and runtime-owned validators, but cannot change inherited property type, database mapping, required or unique behavior, foreign key settings, or file and image storage options.
## Safe Automation Workflow
After the MCP client connects, let it discover the available capabilities from the server. The normal write workflow should still be conservative:
1. Read the current runtime item before planning a change.
2. Fetch the current mutation metadata and concurrency stamp.
3. Build a small mutation batch that touches only the changed semantic paths.
4. Validate the batch before writing.
5. Apply the validated batch.
6. Re-read the changed item and review [Health](health.md).
Treat validation and health review as part of the normal MCP workflow. They are especially important for forms, dashboard layouts, relation changes, and delete operations that can leave broken references.
## Troubleshooting
| Problem | Check |
|---------|-------|
| `401 Unauthorized` | The MCP client is not sending `Authorization: Bearer <access-token>`, the token expired, or the token was issued by an authority that the backend host does not trust. |
| `403 Forbidden` | The OpenIddict client does not have the required `AbpLowCodeDesigner.*` permissions. |
| Client cannot connect | Verify that the backend host is running and that the URL points to `/mcp/low-code-designer` on the HTTP API host. |
| Writes fail | Grant `AbpLowCodeDesigner.Edit`, refresh the token, and rebuild the change from the latest runtime metadata. |
| Script dry-run fails with authorization | Grant `AbpLowCodeDesigner.ScriptTest` and generate a new token. |
## Designer and MCP Together
Use the Designer when you want interactive editing and visual review. Use MCP when a remote agent or automation needs authenticated, repeatable runtime changes. After MCP-driven changes, reopen the relevant Designer section or review [Health](health.md) before treating the runtime model as ready.
## See Also
* [Low-Code Designer](designer.md)
* [Health](health.md)
* [Model Descriptor Files](model-json.md)
* [OpenIddict Module (Pro)](../modules/openiddict-pro.md#creating-a-client-credentials-application)
* [Permission Management Module](../modules/permission-management.md#permission-management-dialog)

199
docs/en/low-code/model-json.md

@ -9,26 +9,70 @@
> **Preview:** The Low-Code System is currently in preview. The descriptor format is stable enough for evaluation and source control, but fields may change before general availability.
Low-code metadata is source-controlled as JSON descriptor files used by the Low-Code Designer and React runtime. Current generated projects keep descriptors as split files under `_Dynamic`; older projects may still contain an aggregate `_Dynamic/model.json` document. Use the [Low-Code Designer](designer.md) for normal editing. Use this page when you need to review, generate, merge, or source-control the JSON metadata directly.
Low-code metadata is source-controlled as JSON descriptor files used by the Low-Code Designer and React runtime. Current generated projects keep descriptors as split files under `_Dynamic/model`; older projects may still contain an aggregate `_Dynamic/model.json` document. Use the [Low-Code Designer](designer.md) for normal editing. Use this page when you need to review, generate, merge, or source-control the JSON metadata directly.
## File Location
Generated low-code applications keep descriptor files in a `_Dynamic` folder under the application domain project. A typical project stores one JSON file per descriptor:
Generated low-code applications keep descriptor files under `_Dynamic/model/` plus a generated initializer in the same `_Dynamic` folder. A typical layered application stores one JSON file per descriptor and keeps starter examples separately:
```text
YourApp.Domain/
`-- _Dynamic/
|-- entities/
| `-- Acme.Campaigns.Campaign.json
|-- pages/
| `-- campaigns.json
|-- forms/
| `-- campaign-form.json
`-- permissions/
`-- Acme.Campaigns.json
|-- YourAppLowCodeInitializer.cs
|-- model/
| |-- entities/
| | `-- Acme/Catalog/Product.json
| |-- pages/
| | `-- products.json
| |-- forms/
| | `-- product-form.json
| `-- permissions/
| `-- Acme.Catalog.json
`-- model-examples/
|-- product.entity.json
|-- product-form.form.json
`-- products-page.page.json
```
Exact folders and file names are generated by the tooling for the descriptor type. Keep the whole `_Dynamic` folder and the generated initializer in source control. The low-code module discovers the descriptor metadata during application startup.
Single-layer applications use the same `_Dynamic` layout under the host project instead of the domain project.
Keep the whole `_Dynamic` folder and the generated initializer in source control. The runtime scans `_Dynamic/model/**/*.json`; files in `model-examples/` are ignored until you copy them into the matching category folder under `model/`.
Layout conventions:
* `entities/` and `enums/` usually follow namespace-like folders such as `entities/Acme/Catalog/Product.json`.
* `pageGroups/` usually stays flat as `pageGroups/{groupName}.json`.
* `pages/` stays flat as `pages/{pageName}.json`, even when the page belongs to a page group or is a dashboard.
* Dashboard pages live in `pages/`; there is no separate dashboard descriptor folder.
* The runtime scans the directory tree directly. Do not add a combined index file next to `model/`.
## Example Files and Validation
ABP Studio-generated solutions include a `model-examples/` folder with starter descriptors that are safe to keep in source control but are not loaded by the runtime until copied into `model/`.
Typical example copies:
* `product.entity.json` -> `_Dynamic/model/entities/YourCompany/YourApp/Catalog/Product.json`
* `product-form.form.json` -> `_Dynamic/model/forms/product-form.json`
* `products-page.page.json` -> `_Dynamic/model/pages/products.json`
Copying example files into `model/` activates the metadata, but entity and mapped-property changes still require the generated migration workflow before runtime CRUD pages can query the backing table safely.
After manual JSON edits, validate the descriptor files with the generated startup project before running migrations or shipping changes:
```bash
dotnet run --project <startup-project> -- --check-lowcode-model-files
```
To validate a specific folder instead of the configured source assemblies, pass `--model-directory`:
```bash
dotnet run --project <startup-project> -- --check-lowcode-model-files --model-directory "<path-to-_Dynamic/model>"
```
The file checker reports invalid JSON, wrong category placement, duplicate descriptor names, and model materialization errors before the runtime loads the descriptors.
It does not catch every runtime-semantic UI problem. For example, a form file can still pass the checker while rendering an empty group at runtime if the placement `fieldId` values do not match the form field definitions or if the expected group placements are missing. Use [Health](health.md), the Designer preview, or the runtime page itself to catch those issues.
## JSON Schemas
@ -66,8 +110,8 @@ Use the descriptor schema directly when a descriptor is stored as its own JSON f
```json
{
"$schema": "https://raw.githubusercontent.com/abpframework/abp/rel-10.5/schemas/low-code/definitions/entity-descriptor.schema.json",
"name": "Acme.Campaigns.Campaign",
"displayName": "Campaigns",
"name": "Acme.Catalog.Product",
"displayName": "Products",
"properties": []
}
```
@ -116,7 +160,7 @@ Define enums before properties that reference them:
{
"enums": [
{
"name": "Acme.Campaigns.CampaignStatus",
"name": "Acme.Catalog.ProductStatus",
"values": [
{ "name": "Draft", "value": 0 },
{ "name": "Active", "value": 1 },
@ -134,7 +178,7 @@ Use the enum from a property with `type: "enum"` and `enumType`:
{
"name": "Status",
"type": "enum",
"enumType": "Acme.Campaigns.CampaignStatus",
"enumType": "Acme.Catalog.ProductStatus",
"defaultValue": "0"
}
```
@ -145,8 +189,8 @@ Entities describe the persisted data model. UI is not configured with legacy pro
```json
{
"name": "Acme.Campaigns.Campaign",
"displayName": "Campaigns",
"name": "Acme.Catalog.Product",
"displayName": "Products",
"displayProperty": "Name",
"properties": [],
"crossFieldValidations": [],
@ -156,7 +200,7 @@ Entities describe the persisted data model. UI is not configured with legacy pro
| Field | Description |
|-------|-------------|
| `name` | Required stable full entity name, for example `Acme.Campaigns.Campaign` |
| `name` | Required stable full entity name, for example `Acme.Catalog.Product` |
| `displayName` | Default plural/screen label |
| `displayProperty` | Property shown in lookups and foreign key display values |
| `parent` | Parent entity name for child/detail entities |
@ -169,7 +213,7 @@ Entities describe the persisted data model. UI is not configured with legacy pro
```json
{
"name": "Budget",
"name": "Price",
"type": "money",
"isRequired": true,
"isUnique": false,
@ -226,11 +270,20 @@ Use `file` or `image` properties for first-class upload fields:
}
```
`imageResizeMode` currently supports:
* `fit`: preserve aspect ratio and scale down to fit within `imageMaxWidth` and `imageMaxHeight`
* `fill`: crop from the center and scale to fill the target box
When `imageResizeMode` is `fill`, set both `imageMaxWidth` and `imageMaxHeight`. If either dimension is missing, the current React runtime falls back to `fit`.
Image resizing is currently performed client-side by the React low-code runtime before upload. Backend upload endpoints still validate size and content type, but they store the uploaded bytes as-is. Direct API uploads and scripting uploads do not get automatic server-side resizing.
Use entity `attachments` when each record can have multiple arbitrary files:
```json
{
"name": "Acme.Campaigns.Campaign",
"name": "Acme.Catalog.Product",
"attachments": {
"isEnabled": true,
"maxFileCount": 10,
@ -278,25 +331,25 @@ Pages create runtime routes and menu entries. They also choose how entity data i
```json
{
"name": "campaigns",
"title": "Campaigns",
"icon": "fa-solid fa-bullhorn",
"name": "products",
"title": "Products",
"icon": "fa-solid fa-box",
"type": "dataGrid",
"entityName": "Acme.Campaigns.Campaign",
"group": "marketing",
"entityName": "Acme.Catalog.Product",
"group": "catalog",
"defaultFileExportMode": 0,
"allowFileBundleExport": true,
"columns": [
{ "propertyName": "Name", "order": 0, "exportOrder": 0 },
{ "propertyName": "Status", "order": 1, "exportOrder": 1 },
{ "propertyName": "Budget", "order": 2, "exportOrder": 2, "exportable": false }
{ "propertyName": "Price", "order": 2, "exportOrder": 2, "exportable": false }
],
"filters": [
{ "propertyName": "Name", "control": "text", "defaultOperator": "contains" },
{ "propertyName": "Status", "control": "select", "defaultOperator": "equal" }
],
"createFormName": "campaign-form",
"editFormName": "campaign-form"
"createFormName": "product-form",
"editFormName": "product-form"
}
```
@ -328,6 +381,8 @@ ZIP file bundle export only includes selected page columns that are file or imag
| `form` | `entityName`, `formName` | Standalone form page |
| `dashboard` | `dashboard` | Dashboard visualizations |
Use [Page Groups](page-groups.md) for the `group` reference and menu nesting rules. Use [Dashboards](dashboards.md) for the nested `dashboard` payload, visualization types, and runtime row grouping model.
Runtime routes use the page name:
```text
@ -341,15 +396,17 @@ Runtime routes use the page name:
Forms are named definitions referenced by pages through `formName`, `createFormName`, or `editFormName`.
Use flat field placements inside each group. The current runtime and designer read `layout.tabs[].groups[].fields[]`, where each item references a `fieldId` and assigns `row`, `colSpan`, and optional `colStart`. If those placements are missing or reference the wrong field IDs, the form can still define fields while a group renders empty.
```json
{
"name": "campaign-form",
"entityName": "Acme.Campaigns.Campaign",
"name": "product-form",
"entityName": "Acme.Catalog.Product",
"enableSaveAndNew": true,
"fields": [
{ "id": "name", "label": "Name", "type": "text", "binding": "Name" },
{ "id": "status", "label": "Status", "type": "select", "binding": "Status", "enumType": "Acme.Campaigns.CampaignStatus" },
{ "id": "ownerId", "label": "Owner", "type": "lookup", "binding": "OwnerId" }
{ "id": "status", "label": "Status", "type": "select", "binding": "Status", "enumType": "Acme.Catalog.ProductStatus" },
{ "id": "price", "label": "Price", "type": "money", "binding": "Price" }
],
"layout": {
"tabs": [
@ -365,7 +422,7 @@ Forms are named definitions referenced by pages through `formName`, `createFormN
"fields": [
{ "fieldId": "name", "row": 0, "colSpan": 4 },
{ "fieldId": "status", "row": 1, "colSpan": 2 },
{ "fieldId": "ownerId", "row": 1, "colSpan": 2 }
{ "fieldId": "price", "row": 1, "colSpan": 2 }
]
}
]
@ -400,9 +457,9 @@ Pages can use generated defaults or explicit permission configuration:
{
"permissionConfig": {
"view": "authenticated",
"create": "Acme.Campaigns.Create",
"update": "Acme.Campaigns.Update",
"delete": "Acme.Campaigns.Delete"
"create": "Acme.Catalog.Create",
"update": "Acme.Catalog.Update",
"delete": "Acme.Catalog.Delete"
}
}
```
@ -433,12 +490,12 @@ See [Interceptors](interceptors.md) and [Scripting API](scripting-api.md).
{
"endpoints": [
{
"name": "GetCampaignStats",
"route": "/api/custom/campaigns/stats",
"name": "GetProductStats",
"route": "/api/custom/products/stats",
"method": "GET",
"requireAuthentication": true,
"requiredPermissions": ["Acme.Campaigns"],
"javascript": "var count = await db.count('Acme.Campaigns.Campaign'); return ok({ total: count });"
"requiredPermissions": ["Acme.Catalog"],
"javascript": "var count = await db.count('Acme.Catalog.Product'); return ok({ total: count });"
}
]
}
@ -452,22 +509,22 @@ See [Custom Endpoints](custom-endpoints.md).
{
"eventHandlers": [
{
"name": "NotifyCampaignCompleted",
"eventName": "Acme.Campaigns.CampaignCompleted",
"javascript": "log('Campaign completed: ' + eventData.id);"
"name": "NotifyProductPublished",
"eventName": "Acme.Catalog.ProductPublished",
"javascript": "context.log('Product published: ' + eventData.id);"
}
],
"backgroundJobs": [
{
"name": "SendCampaignSummary",
"javascript": "log('Sending summary for ' + jobData.campaignId);"
"name": "SendProductSummary",
"javascript": "context.log('Sending summary for ' + jobData.productId);"
}
],
"backgroundWorkers": [
{
"name": "CampaignCleanup",
"name": "ProductCleanup",
"period": 3600000,
"javascript": "log('Cleaning campaign data.');"
"javascript": "context.log('Cleaning product data.');"
}
]
}
@ -483,37 +540,37 @@ The complete example below shows the logical aggregate shape. Split projects sto
{
"enums": [
{
"name": "Acme.Campaigns.CampaignStatus",
"name": "Acme.Catalog.ProductStatus",
"values": [
{ "name": "Draft", "value": 0 },
{ "name": "Active", "value": 1 },
{ "name": "Completed", "value": 2 }
{ "name": "Archived", "value": 2 }
]
}
],
"entities": [
{
"name": "Acme.Campaigns.Campaign",
"displayName": "Campaigns",
"name": "Acme.Catalog.Product",
"displayName": "Products",
"displayProperty": "Name",
"properties": [
{ "name": "Name", "type": "string", "isRequired": true, "validators": [{ "type": "maxLength", "length": 128 }] },
{ "name": "Status", "type": "enum", "enumType": "Acme.Campaigns.CampaignStatus", "defaultValue": "0" },
{ "name": "Budget", "type": "money" },
{ "name": "StartDate", "type": "date" },
{ "name": "Status", "type": "enum", "enumType": "Acme.Catalog.ProductStatus", "defaultValue": "0" },
{ "name": "Price", "type": "money" },
{ "name": "ReleaseDate", "type": "date" },
{ "name": "CoverImage", "type": "image", "fileAllowedContentTypes": ["image/*"] }
]
}
],
"forms": [
{
"name": "campaign-form",
"entityName": "Acme.Campaigns.Campaign",
"name": "product-form",
"entityName": "Acme.Catalog.Product",
"fields": [
{ "id": "name", "label": "Name", "type": "text", "binding": "Name" },
{ "id": "status", "label": "Status", "type": "select", "binding": "Status", "enumType": "Acme.Campaigns.CampaignStatus" },
{ "id": "budget", "label": "Budget", "type": "money", "binding": "Budget" },
{ "id": "startDate", "label": "Start Date", "type": "date", "binding": "StartDate" },
{ "id": "status", "label": "Status", "type": "select", "binding": "Status", "enumType": "Acme.Catalog.ProductStatus" },
{ "id": "price", "label": "Price", "type": "money", "binding": "Price" },
{ "id": "releaseDate", "label": "Release Date", "type": "date", "binding": "ReleaseDate" },
{ "id": "coverImage", "label": "Cover Image", "type": "image", "binding": "CoverImage" }
],
"layout": {
@ -530,8 +587,8 @@ The complete example below shows the logical aggregate shape. Split projects sto
"fields": [
{ "fieldId": "name", "row": 0, "colSpan": 4 },
{ "fieldId": "status", "row": 1, "colSpan": 2 },
{ "fieldId": "budget", "row": 1, "colSpan": 2 },
{ "fieldId": "startDate", "row": 2, "colSpan": 2 },
{ "fieldId": "price", "row": 1, "colSpan": 2 },
{ "fieldId": "releaseDate", "row": 2, "colSpan": 2 },
{ "fieldId": "coverImage", "row": 2, "colSpan": 2 }
]
}
@ -542,27 +599,27 @@ The complete example below shows the logical aggregate shape. Split projects sto
}
],
"pageGroups": [
{ "name": "marketing", "title": "Marketing", "icon": "fa-solid fa-bullhorn", "order": 10 }
{ "name": "catalog", "title": "Catalog", "icon": "fa-solid fa-boxes-stacked", "order": 10 }
],
"pages": [
{
"name": "campaigns",
"title": "Campaigns",
"name": "products",
"title": "Products",
"type": "dataGrid",
"entityName": "Acme.Campaigns.Campaign",
"group": "marketing",
"entityName": "Acme.Catalog.Product",
"group": "catalog",
"columns": [
{ "propertyName": "Name", "order": 0, "exportOrder": 0 },
{ "propertyName": "Status", "order": 1, "exportOrder": 1 },
{ "propertyName": "Budget", "order": 2, "exportOrder": 2, "exportable": false }
{ "propertyName": "Price", "order": 2, "exportOrder": 2, "exportable": false }
],
"filters": [
{ "propertyName": "Name", "control": "text", "defaultOperator": "contains" },
{ "propertyName": "Status", "control": "select", "defaultOperator": "equal" },
{ "propertyName": "CoverImage", "control": "exists", "defaultOperator": "hasValue" }
],
"createFormName": "campaign-form",
"editFormName": "campaign-form"
"createFormName": "product-form",
"editFormName": "product-form"
}
]
}
@ -584,6 +641,10 @@ In ABP Studio, run the generated migration task for the solution. If you run the
* [Low-Code Designer](designer.md)
* [React Runtime](react-runtime.md)
* [Health](health.md)
* [Dashboards](dashboards.md)
* [Page Groups](page-groups.md)
* [MCP Integration](mcp.md)
* [Attributes & Fluent API](fluent-api.md)
* [Interceptors](interceptors.md)
* [Custom Endpoints](custom-endpoints.md)

123
docs/en/low-code/page-groups.md

@ -0,0 +1,123 @@
```json
//[doc-seo]
{
"Description": "Define ABP Low-Code page groups to organize runtime pages into dynamic menu folders with ordering, icons, and nesting."
}
```
# Page Groups
Page groups are dynamic menu folders used by low-code pages. They let you organize runtime pages into nested navigation structures without hardcoding menu items in the frontend.
## What a Page Group Stores
A page group descriptor stores:
| Field | Description |
|-------|-------------|
| `name` | Stable identifier used by pages in their `group` field |
| `title` | Display label shown in the runtime menu |
| `icon` | CSS class string for the menu icon |
| `order` | Sort order among sibling groups |
| `parent` | Optional parent group name for nesting |
If a group does not define an icon, the runtime menu uses a folder-style default icon.
## How Pages Use Groups
Pages reference a group by name:
```json
{
"name": "products",
"title": "Products",
"type": "dataGrid",
"entityName": "Acme.Catalog.Product",
"group": "inventory"
}
```
If `group` is omitted, the page becomes a top-level runtime menu item.
A group is only a container. It does not define page data, routes, or entity behavior by itself.
## Nesting Rules
Groups can nest by pointing `parent` to another page group name.
Important constraints:
* Group nesting cannot be circular.
* Maximum depth is `3` levels.
* Root groups are groups whose `parent` is empty.
This keeps runtime navigation predictable and avoids unbounded nesting in the generated menu.
## Visibility and Permissions
Page groups do not replace page permissions. Runtime visibility still depends on the pages inside the group.
In practice:
* Pages are added to the runtime menu only if the current user can access them.
* Groups are containers for those visible pages.
* Groups without visible page descendants are omitted from the runtime root menu.
This means page permissions remain the real security boundary, while page groups control menu organization.
## Split Descriptor Example
In split descriptor projects, a page group file in `pageGroups/` stores **one descriptor object**, not a wrapper document:
```json
{
"name": "inventory-insights",
"title": "Insights",
"icon": "fa-solid fa-folder-tree",
"order": 20,
"parent": "inventory"
}
```
For example, `pageGroups/inventory-insights.json` would use that shape directly.
If you are looking at the logical aggregate model instead of split files, the same data appears under the top-level `pageGroups` array:
```json
{
"pageGroups": [
{
"name": "inventory",
"title": "Inventory",
"icon": "fa-solid fa-boxes-stacked",
"order": 10
},
{
"name": "inventory-insights",
"title": "Insights",
"icon": "fa-solid fa-folder-tree",
"order": 20,
"parent": "inventory"
}
]
}
```
In split descriptor projects, page groups belong to the `pageGroups/` descriptor category.
## Icon Guidance
Use CSS class strings for icons, for example:
* `fa-solid fa-folder`
* `fa-solid fa-folder-tree`
* `fa-solid fa-boxes-stacked`
Do not treat `icon` as an image URL or file path.
## See Also
* [Dashboards](dashboards.md)
* [React Runtime](react-runtime.md)
* [Low-Code Designer](designer.md)
* [Model Descriptor Files](model-json.md)

17
docs/en/low-code/react-runtime.md

@ -97,6 +97,8 @@ const { data: dynamicMenuItems } = useMenuItems({
Each menu item includes its page name, display name, icon, order, grouping information, and children.
Menu grouping and nesting are defined by [Page Groups](page-groups.md).
## Page Types
The runtime includes built-in renderers for these page types:
@ -110,6 +112,8 @@ The runtime includes built-in renderers for these page types:
| `form` | Standalone form page |
| `dashboard` | Dashboard rows with chart, list, and number visualizations |
See [Dashboards](dashboards.md) for the stored descriptor shape, visualization types, and row grouping rules behind dashboard pages.
The generated data grid page includes:
* Search
@ -129,6 +133,8 @@ The generated data grid page includes:
Create and edit forms are rendered from form metadata. Tabs, groups, labels, placeholders, controls, default values, validation rules, conditional form rules, and save actions come from the designer.
Rendered groups come from the form layout placements under `layout.tabs[].groups[].fields[]`. A form can still load with an empty shell when fields exist but no placements reference them.
![Generated create form](images/runtime-create-form.png)
The runtime can render forms in a modal or on full pages. Full-page forms use the dynamic create/edit routes and the `navigate` callback configured in `configureLowCode`.
@ -142,10 +148,12 @@ Filters are rendered as an ABP-style advanced filter area. The runtime shows all
File and image filters use a single `Has value` concept. The value selector controls whether the filter is applied:
* `All` does not add a filter.
* `Yes` returns records with a value.
* `No` returns records without a value.
* `Yes` returns `true` for boolean filters, or records with a value for `Has value` filters.
* `No` returns `false` for boolean filters, or records without a value for `Has value` filters.
The screenshot below shows the shared selector pattern on the `Active` boolean filter.
![Has value options](images/runtime-filters-has-value.png)
![Yes/No filter options](images/runtime-filters-has-value.png)
The URL keeps the existing `lcFilters` query parameter shape. The runtime maps user-friendly filter choices to the existing backend `FilterType` values.
@ -185,6 +193,9 @@ Troubleshooting:
| Symptom | Likely cause |
|---------|--------------|
| `403` on a generated page or missing dynamic menu items | The signed-in role or user does not have the generated low-code permissions |
| `Invalid object name ...` after copying source-controlled descriptors | The descriptors were loaded, but the backing table has not been created yet. Run the generated migration or database update task |
| Form shell loads but no fields appear | The form layout is missing `layout.tabs[].groups[].fields[]` placements or the placement `fieldId` values do not match the form fields |
| Invalid or expired download token | The token is single-use, expired, or was requested for a different page/context |
| Export row limit exceeded | Narrow the filters, export the current page, or increase `LowCode:Export:MaxRows` |
| File link expired | Re-run export; temporary file links are intentionally short-lived |

3
docs/en/low-code/reference-entities.md

@ -13,6 +13,8 @@ Use the [Low-Code Designer](designer.md) to select reference entities after they
Reference Entities allow you to create foreign key relationships from **dynamic entities** to **existing C# entities** that live outside the Low-Code System.
If you need the opposite direction, where regular C# code reads or writes low-code entities, see [Code Integration](code-integration.md).
## Dynamic Entities vs Reference Entities
| | Dynamic Entities | Reference Entities |
@ -147,5 +149,6 @@ if (user) {
## See Also
* [Model Descriptor Files](model-json.md)
* [Code Integration](code-integration.md)
* [Foreign Access](foreign-access.md)
* [Attributes & Fluent API](fluent-api.md)

4
docs/en/low-code/script-actions.md

@ -100,7 +100,7 @@ Event handlers run when a distributed event with the configured name is publishe
"name": "NotifyCampaignCompleted",
"eventName": "Acme.Campaigns.CampaignCompleted",
"description": "Logs and notifies when a campaign is completed",
"javascript": "log('Campaign completed: ' + eventData.id);\nawait email.queueAsync('ops@example.com', 'Campaign completed', eventData.id);"
"javascript": "context.log('Campaign completed: ' + eventData.id);\nawait email.queueAsync('ops@example.com', 'Campaign completed', eventData.id);"
}
]
}
@ -194,7 +194,7 @@ Configure either `period` or `cronExpression`.
"name": "CampaignCleanup",
"period": 3600000,
"description": "Runs every hour",
"javascript": "var query = await db.query('Acme.Campaigns.Campaign');\nvar stale = await query.where(c => c.Status === 0).take(100).toList();\nlog('Stale draft count: ' + stale.length);"
"javascript": "var query = await db.query('Acme.Campaigns.Campaign');\nvar stale = await query.where(c => c.Status === 0).take(100).toList();\ncontext.log('Stale draft count: ' + stale.length);"
}
]
}

82
docs/en/low-code/scripting-api.md

@ -177,12 +177,12 @@ orderLines.forEach(line => {
```javascript
var orderQuery = await db.query('LowCodeDemo.Orders.Order');
var orders = await orderQuery
.leftJoin('LowCodeDemo.Products.Product', 'p', (o, p) => o.CustomerId === p.Id)
.leftJoin('LowCodeDemo.Customers.Customer', 'c', (o, c) => o.CustomerId === c.Id)
.toList();
orders.forEach(order => {
if (order.p) {
context.log('Has match: ' + order.p.Name);
if (order.c) {
context.log('Has match: ' + order.c.Name);
}
});
```
@ -190,21 +190,21 @@ orders.forEach(order => {
### LINQ-Style Join
```javascript
var orderQuery = await db.query('Order');
orderQuery
var orderLineQuery = await db.query('LowCodeDemo.Orders.OrderLine');
orderLineQuery
.join('LowCodeDemo.Products.Product',
o => o.ProductId,
ol => ol.ProductId,
p => p.Id)
```
### Join with Filtered Query
```javascript
var productQuery = await db.query('Product');
var productQuery = await db.query('LowCodeDemo.Products.Product');
var expensiveProducts = productQuery.where(p => p.Price > 100);
var orderLineQuery = await db.query('OrderLine');
var orders = await orderLineQuery
var orderLineQuery = await db.query('LowCodeDemo.Orders.OrderLine');
var orderLines = await orderLineQuery
.join(expensiveProducts,
ol => ol.ProductId,
p => p.Id)
@ -223,12 +223,12 @@ Set operations execute at the database level using SQL:
| `except(query)` | `EXCEPT` | Elements in first, not second |
```javascript
var productQuery = await db.query('Product');
var productQuery = await db.query('LowCodeDemo.Products.Product');
var cheap = productQuery.where(x => x.Price <= 100);
var popular = productQuery.where(x => x.Rating > 4);
var inStock = productQuery.where(x => x.StockCount > 0);
var bestDeals = await cheap.intersect(popular).toList();
var underrated = await cheap.except(popular).toList();
var affordableInStock = await cheap.intersect(inStock).toList();
var soldOutBudgetItems = await cheap.except(inStock).toList();
```
## Aggregation Methods
@ -245,26 +245,26 @@ All aggregations execute as SQL statements:
| `groupBy(x => x.Property)` | `GROUP BY ...` | `QueryBuilder` |
```javascript
var productQuery = await db.query('Product');
var productQuery = await db.query('LowCodeDemo.Products.Product');
var totalValue = await productQuery.sum(x => x.Price);
var avgPrice = await productQuery.where(x => x.InStock).average(x => x.Price);
var avgPrice = await productQuery.where(x => x.StockCount > 0).average(x => x.Price);
var cheapest = await productQuery.min(x => x.Price);
```
### GroupBy with Select
```javascript
var productQuery = await db.query('Product');
var grouped = await productQuery
.groupBy(x => x.Category)
var campaignQuery = await db.query('LowCodeDemo.ContentStudio.Campaign');
var grouped = await campaignQuery
.groupBy(x => x.Status)
.select(g => ({
Category: g.Key,
Status: g.Key,
Count: g.count(),
TotalPrice: g.sum(x => x.Price),
AvgPrice: g.average(x => x.Price),
MinPrice: g.min(x => x.Price),
MaxPrice: g.max(x => x.Price)
TotalBudget: g.sum(x => x.Budget),
AvgBudget: g.average(x => x.Budget),
MinBudget: g.min(x => x.Budget),
MaxBudget: g.max(x => x.Budget)
}))
.toList();
```
@ -285,11 +285,11 @@ var grouped = await productQuery
### GroupBy with Items
```javascript
var productQuery = await db.query('Product');
var grouped = await productQuery
.groupBy(x => x.Category)
var campaignQuery = await db.query('LowCodeDemo.ContentStudio.Campaign');
var grouped = await campaignQuery
.groupBy(x => x.Status)
.select(g => ({
Category: g.Key,
Status: g.Key,
Count: g.count(),
Items: g.take(10).toList()
}))
@ -307,13 +307,13 @@ var grouped = await productQuery
Math functions translate to SQL functions (ROUND, FLOOR, CEILING, ABS, etc.):
```javascript
var productQuery = await db.query('Product');
var productQuery = await db.query('LowCodeDemo.Products.Product');
var products = await productQuery
.where(x => Math.round(x.Price) > 100)
.toList();
var result = await productQuery
.where(x => Math.abs(x.Balance) < 10 && Math.floor(x.Rating) >= 4)
.where(x => Math.abs(x.StockCount) < 10 && Math.floor(x.Price) >= 4)
.toList();
```
@ -442,7 +442,7 @@ Event handlers, background jobs, and background workers are configured in the De
Event handler example:
```javascript
log('Received event ' + eventName);
context.log('Received event ' + eventName);
if (eventData && eventData.campaignId) {
await jobs.enqueueAsync('SendCampaignSummary', {
@ -470,7 +470,7 @@ var staleCount = await campaignQuery
.where(campaign => campaign.Status === 0)
.count();
log('Stale draft campaigns: ' + staleCount);
context.log('Stale draft campaigns: ' + staleCount);
```
## Service Helpers
@ -762,21 +762,21 @@ if (product.StockCount < quantity) { throw new Error('Insufficient stock'); }
context.commandArgs.setValue('TotalAmount', product.Price * quantity);
```
### Sales Dashboard (Custom Endpoint)
### Inventory Overview (Custom Endpoint)
```javascript
var orderQuery = await db.query('LowCodeDemo.Orders.Order');
var productQuery = await db.query('LowCodeDemo.Products.Product');
var totalOrders = await orderQuery.count();
var delivered = await orderQuery
.where(x => x.IsDelivered === true).count();
var revenue = await orderQuery
.where(x => x.IsDelivered === true).sum(x => x.TotalAmount);
var totalProducts = await productQuery.count();
var productsWithStock = await productQuery
.where(x => x.StockCount > 0).count();
var averagePrice = await productQuery
.where(x => x.Price > 0).average(x => x.Price);
return ok({
orders: totalOrders,
delivered: delivered,
revenue: revenue
totalProducts: totalProducts,
productsWithStock: productsWithStock,
averagePrice: averagePrice
});
```

38
docs/en/modules/openiddict-pro.md

@ -50,6 +50,44 @@ You can create new application or edit existing applications in this page:
![openiddict-edit-application-modal](../images/openiddict-edit-application-modal.png)
##### Creating a Client Credentials Application
Use a client credentials application when a machine-to-machine client, automation, or MCP client needs to call protected backend APIs without an interactive user login.
1. Open **Administration** > **OpenIddict** > **Applications**.
2. Click **New Application**.
3. Enter a unique **Client Id**, for example `InternalAutomationClient`.
4. Set **Client Type** to `Confidential client`.
5. Enter a strong **Client Secret** and store it securely. The secret is required when the client requests a token.
6. Open the **Authorization** tab and enable **Allow client credentials flow**.
7. Open the **Scopes** tab and select the API scopes the client can request.
8. Save the application.
The application screen should show the `client_credentials` grant enabled. Use the **Scopes** tab to select the API scope that the client can request.
![openiddict-client-credentials-application](../images/openiddict-client-credentials-application.png)
If the client will call APIs protected by ABP permissions, grant permissions to the application after saving it. Open the application row's **Actions** menu, select **Permissions**, and grant the required permissions for the **Client (OpenIddict Applications)** provider.
Request an access token from the OpenIddict token endpoint with the `client_credentials` grant:
```bash
curl -X POST "https://localhost:<auth-server-port>/connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=InternalAutomationClient" \
-d "client_secret=<client-secret>" \
-d "scope=<api-scope>"
```
Use the returned `access_token` as a bearer token when calling protected APIs:
```text
Authorization: Bearer <access-token>
```
In non-tiered applications, the OpenIddict authority is typically the backend host. In tiered applications, use the Auth Server URL for `/connect/token`.
#### API Scope Management
OpenIddict module allows to manage API scope. To allow applications to request access tokens for APIs, you need to define API scopes.

Loading…
Cancel
Save