Browse Source

docs: refresh low-code documentation

pull/25780/head
SALİH ÖZKARA 3 months ago
parent
commit
3a5d4f96d3
  1. 4
      docs/en/docs-nav.json
  2. 287
      docs/en/low-code/code-integration.md
  3. 61
      docs/en/low-code/dashboards.md
  4. 4
      docs/en/low-code/designer.md
  5. 1
      docs/en/low-code/fluent-api.md
  6. BIN
      docs/en/low-code/images/designer-forms.png
  7. BIN
      docs/en/low-code/images/designer-page-filters.png
  8. BIN
      docs/en/low-code/images/runtime-create-form.png
  9. BIN
      docs/en/low-code/images/runtime-dashboard.png
  10. BIN
      docs/en/low-code/images/runtime-data-grid.png
  11. BIN
      docs/en/low-code/images/runtime-filters-has-value.png
  12. BIN
      docs/en/low-code/images/runtime-filters.png
  13. 8
      docs/en/low-code/index.md
  14. 87
      docs/en/low-code/mcp.md
  15. 126
      docs/en/low-code/model-json.md
  16. 30
      docs/en/low-code/page-groups.md
  17. 8
      docs/en/low-code/react-runtime.md
  18. 3
      docs/en/low-code/reference-entities.md
  19. 4
      docs/en/low-code/script-actions.md
  20. 82
      docs/en/low-code/scripting-api.md

4
docs/en/docs-nav.json

@ -2145,6 +2145,10 @@
"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"

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)

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

@ -7,7 +7,7 @@
# 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.
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:
@ -46,50 +46,47 @@ At runtime, the React UI groups those flat visualizations into rendered rows. Th
```json
{
"name": "sales-dashboard",
"title": "Sales Dashboard",
"name": "inventory-overview",
"title": "Inventory Overview",
"type": "dashboard",
"group": "analytics",
"group": "inventory",
"dashboard": {
"description": "Operational sales view",
"globalFilters": [
{ "type": "dateRange" }
],
"description": "Operational inventory view",
"visualizations": [
{
"name": "sales-by-status",
"type": "chart",
"title": "Sales by Status",
"row": 0,
"order": 0,
"width": 2,
"entityName": "Acme.Sales.Order",
"chart": {
"chartType": "bar",
"xAxis": { "property": "Status" },
"yAxis": [
{ "aggregation": "count", "label": "Orders" }
]
}
},
{
"name": "totals",
"name": "product-count",
"type": "numberContainer",
"title": "Totals",
"row": 1,
"title": "Product Count",
"row": 0,
"order": 0,
"width": 2,
"width": 1,
"numberContainer": {
"items": [
{
"name": "order-count",
"title": "Order Count",
"entityName": "Acme.Sales.Order",
"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" }
]
}
}
]
}
@ -136,6 +133,8 @@ Number containers hold one or more number items. Each item can define:
* 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:

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

@ -109,7 +109,7 @@ For example, this form defines three fields and places those same field IDs into
"fields": [
{ "id": "name", "label": "Name", "type": "text", "binding": "Name" },
{ "id": "price", "label": "Price", "type": "money", "binding": "Price" },
{ "id": "is-active", "label": "Active", "type": "checkbox", "binding": "IsActive" }
{ "id": "stock-count", "label": "Stock Count", "type": "number", "binding": "StockCount" }
],
"layout": {
"tabs": [
@ -119,7 +119,7 @@ For example, this form defines three fields and places those same field IDs into
"fields": [
{ "fieldId": "name", "row": 0, "colSpan": 4 },
{ "fieldId": "price", "row": 1, "colSpan": 2 },
{ "fieldId": "is-active", "row": 1, "colSpan": 2 }
{ "fieldId": "stock-count", "row": 1, "colSpan": 2 }
]
}
]

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

@ -571,5 +571,6 @@ This gives you four auto-generated pages (Customers, Products, Orders with neste
## See Also
* [Model Descriptor Files](model-json.md)
* [Code Integration](code-integration.md)
* [Reference Entities](reference-entities.md)
* [Interceptors](interceptors.md)

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 47 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: 58 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 46 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 35 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 44 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: 59 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 60 KiB

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

@ -129,11 +129,11 @@ React low-code filters are type-aware. The runtime shows only operators that mak
* 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
@ -180,6 +180,7 @@ The designer stores and reads the same descriptor metadata described in the refe
| [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 |
@ -205,4 +206,5 @@ The generated pages are powered by these services:
* [Dashboards](dashboards.md)
* [Page Groups](page-groups.md)
* [MCP Integration](mcp.md)
* [Code Integration](code-integration.md)
* [Model Descriptor Files](model-json.md)

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

@ -21,6 +21,93 @@ The MCP surface is **runtime-only**:
Use [Model Descriptor Files](model-json.md) when you need source-controlled descriptors. Use MCP when an agent or automation needs safe, structured changes to the runtime model that the Designer can immediately show.
## Tool Reference
All tools below work against the **runtime database-backed** model only.
### Core Workflow Tools
| Tool | Use it for |
|------|------------|
| `lowcode_designer_get_capabilities` | Read the server's runtime-only scope, write rules, recommended workflow, and example mutation targets |
| `lowcode_designer_get_mutation_metadata` | Fetch the current `concurrencyStamp` and the valid semantic target tree before planning a write |
| `lowcode_designer_validate_mutations` | Dry-run a mutation batch and get structured validation feedback without writing |
| `lowcode_designer_apply_mutations` | Apply a validated mutation batch to the runtime model; this is the **only write tool** |
| `lowcode_designer_get_health_snapshot` | Re-check the full runtime model after changes to catch broken references or invalid layouts |
### Entity and Enum Tools
| Tool | Use it for |
|------|------------|
| `lowcode_designer_get_entities` | List runtime entities; optionally include configured reference entities |
| `lowcode_designer_get_entity` | Read one entity with properties, validators, interceptors, and child metadata |
| `lowcode_designer_get_entity_delete_impact` | Review references that would break or change if an entity is removed |
| `lowcode_designer_get_enum_types` | List available enum types |
| `lowcode_designer_get_enum` | Read one enum and its values |
| `lowcode_designer_get_enum_delete_impact` | Review what depends on an enum before removing it |
### Page, Form, and Permission Tools
| Tool | Use it for |
|------|------------|
| `lowcode_designer_get_page_groups` | List page groups used for runtime navigation folders |
| `lowcode_designer_get_pages` | List pages across page types, including dashboard pages |
| `lowcode_designer_get_page` | Read one page descriptor, including columns, filters, dashboard config, and form links |
| `lowcode_designer_get_page_permission_config` | Read the page's permission bindings before changing access rules |
| `lowcode_designer_get_forms` | List forms |
| `lowcode_designer_get_form` | Read one form with fields, flat layout placements, validations, and rules |
| `lowcode_designer_get_form_delete_impact` | Review what depends on a form before removing it |
| `lowcode_designer_get_permissions` | List runtime low-code permission definitions |
| `lowcode_designer_get_permission_delete_impact` | Review where a permission is referenced before removing it |
### Script and Action Tools
| Tool | Use it for |
|------|------------|
| `lowcode_designer_get_endpoints` | List scripted custom endpoints |
| `lowcode_designer_get_endpoint` | Read one endpoint with route, method, JavaScript, and analyzed references |
| `lowcode_designer_get_script_autocomplete_metadata` | Discover the script globals, helpers, entities, and metadata available for a script type |
| `lowcode_designer_test_script` | Dry-run endpoint, interceptor, event handler, background job, or background worker JavaScript |
| `lowcode_designer_get_script_event_handlers` | List script event handlers |
| `lowcode_designer_get_script_event_handler` | Read one event handler |
| `lowcode_designer_get_script_background_jobs` | List script background jobs |
| `lowcode_designer_get_script_background_job` | Read one background job |
| `lowcode_designer_get_script_background_worker_scheduler_capabilities` | Read whether the runtime supports dynamic worker registration and cron scheduling |
| `lowcode_designer_get_script_background_workers` | List script background workers |
| `lowcode_designer_get_script_background_worker` | Read one background worker with schedule and JavaScript |
## Common Sequences
### Change an Entity or Form
1. Read the current item with `lowcode_designer_get_entity` or `lowcode_designer_get_form`.
2. Fetch `lowcode_designer_get_mutation_metadata`.
3. Build a small mutation batch against the returned target tree.
4. Run `lowcode_designer_validate_mutations`.
5. Apply with `lowcode_designer_apply_mutations`.
6. Re-read the item and run `lowcode_designer_get_health_snapshot`.
### Change a Dashboard or Page Group
1. Read the page with `lowcode_designer_get_page` or list groups with `lowcode_designer_get_page_groups`.
2. Fetch mutation metadata and validate the exact batch.
3. Apply the change.
4. Re-open the page via `lowcode_designer_get_page` and confirm health.
### Remove a Shared Item Safely
1. Call the matching delete-impact tool first, such as `lowcode_designer_get_entity_delete_impact`, `lowcode_designer_get_enum_delete_impact`, `lowcode_designer_get_form_delete_impact`, or `lowcode_designer_get_permission_delete_impact`.
2. Fix or remove dependent references.
3. Validate the removal batch.
4. Apply and then re-check health.
### Review or Debug a Script
1. Read the action or endpoint with its `get_*` tool.
2. Fetch `lowcode_designer_get_script_autocomplete_metadata` for the relevant script type.
3. Run `lowcode_designer_test_script`.
4. Persist the change through mutations only after the dry run looks correct.
## Mental Model
MCP is not a raw JSON document editor. It is a semantic mutation layer over low-code concepts such as:

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

@ -21,13 +21,13 @@ YourApp.Domain/
|-- YourAppLowCodeInitializer.cs
|-- model/
| |-- entities/
| | `-- Acme/Campaigns/Campaign.json
| | `-- Acme/Catalog/Product.json
| |-- pages/
| | `-- campaigns.json
| | `-- products.json
| |-- forms/
| | `-- campaign-form.json
| | `-- product-form.json
| `-- permissions/
| `-- Acme.Campaigns.json
| `-- Acme.Catalog.json
`-- model-examples/
|-- product.entity.json
|-- product-form.form.json
@ -40,7 +40,7 @@ Keep the whole `_Dynamic` folder and the generated initializer in source control
Layout conventions:
* `entities/` and `enums/` usually follow namespace-like folders such as `entities/Acme/Campaigns/Campaign.json`.
* `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.
@ -110,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": []
}
```
@ -160,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 },
@ -178,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"
}
```
@ -189,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": [],
@ -200,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 |
@ -213,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,
@ -274,7 +274,7 @@ 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,
@ -322,25 +322,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"
}
```
@ -391,13 +391,13 @@ Use flat field placements inside each group. The current runtime and designer re
```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": [
@ -413,7 +413,7 @@ Use flat field placements inside each group. The current runtime and designer re
"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 }
]
}
]
@ -448,9 +448,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"
}
}
```
@ -481,12 +481,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 });"
}
]
}
@ -500,22 +500,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.');"
}
]
}
@ -531,37 +531,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": {
@ -578,8 +578,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 }
]
}
@ -590,27 +590,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"
}
]
}

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

@ -29,11 +29,11 @@ Pages reference a group by name:
```json
{
"name": "orders",
"title": "Orders",
"name": "products",
"title": "Products",
"type": "dataGrid",
"entityName": "Acme.Sales.Order",
"group": "sales"
"entityName": "Acme.Catalog.Product",
"group": "inventory"
}
```
@ -71,15 +71,15 @@ In split descriptor projects, a page group file in `pageGroups/` stores **one de
```json
{
"name": "sales-reports",
"title": "Reports",
"name": "inventory-insights",
"title": "Insights",
"icon": "fa-solid fa-folder-tree",
"order": 20,
"parent": "sales"
"parent": "inventory"
}
```
For example, `pageGroups/sales-reports.json` would use that shape directly.
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:
@ -87,17 +87,17 @@ If you are looking at the logical aggregate model instead of split files, the sa
{
"pageGroups": [
{
"name": "sales",
"title": "Sales",
"icon": "fa-solid fa-chart-line",
"name": "inventory",
"title": "Inventory",
"icon": "fa-solid fa-boxes-stacked",
"order": 10
},
{
"name": "sales-reports",
"title": "Reports",
"name": "inventory-insights",
"title": "Insights",
"icon": "fa-solid fa-folder-tree",
"order": 20,
"parent": "sales"
"parent": "inventory"
}
]
}
@ -111,7 +111,7 @@ Use CSS class strings for icons, for example:
* `fa-solid fa-folder`
* `fa-solid fa-folder-tree`
* `fa-solid fa-chart-line`
* `fa-solid fa-boxes-stacked`
Do not treat `icon` as an image URL or file path.

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

@ -148,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.
![Has value options](images/runtime-filters-has-value.png)
The screenshot below shows the shared selector pattern on the `Active` boolean filter.
![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.

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
});
```

Loading…
Cancel
Save