diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 6fcb8ebda1..32c47a832e 100644 --- a/docs/en/docs-nav.json +++ b/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": [ diff --git a/docs/en/images/openiddict-client-credentials-application.png b/docs/en/images/openiddict-client-credentials-application.png new file mode 100644 index 0000000000..fdcf4a2e5b Binary files /dev/null and b/docs/en/images/openiddict-client-credentials-application.png differ diff --git a/docs/en/low-code/code-integration.md b/docs/en/low-code/code-integration.md new file mode 100644 index 0000000000..6a42f1fc26 --- /dev/null +++ b/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` for that `[DynamicEntity]` class | +| Application code should work with a descriptor-only or runtime-defined low-code entity | Inject `IRepository` 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( + "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 _customerRepository; + + public CustomerSyncService(IRepository 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`. Set the entity name before repository operations: + +````csharp +public class ProductImportService : ITransientDependency +{ + private readonly IRepository _dynamicEntityRepository; + + public ProductImportService(IRepository dynamicEntityRepository) + { + _dynamicEntityRepository = dynamicEntityRepository; + } + + public async Task 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 GetPriceAsync(Guid id) + { + _dynamicEntityRepository.SetEntityName("LowCodeDemo.Products.Product"); + + var product = await _dynamicEntityRepository.GetAsync(id); + return product.GetData("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("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 CreateAsync() + { + return await _dynamicPageAppService.CreateAsync( + "products", + new DynamicEntityCreateInput + { + Properties = new Dictionary + { + ["Name"] = "Road Helmet", + ["Price"] = "125", + ["StockCount"] = "40" + } + } + ); + } + + public async Task> 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>(); + +var customerQuery = (await _customerRepository.GetQueryableAsync()) + .As>>(); + +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) diff --git a/docs/en/low-code/dashboards.md b/docs/en/low-code/dashboards.md new file mode 100644 index 0000000000..9851932f15 --- /dev/null +++ b/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/ +``` + +## 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) diff --git a/docs/en/low-code/designer.md b/docs/en/low-code/designer.md index 5f6380cc81..9ef7637e5b 100644 --- a/docs/en/low-code/designer.md +++ b/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 diff --git a/docs/en/low-code/fluent-api.md b/docs/en/low-code/fluent-api.md index 74c0caf118..73aa64a534 100644 --- a/docs/en/low-code/fluent-api.md +++ b/docs/en/low-code/fluent-api.md @@ -1,575 +1,650 @@ ```json //[doc-seo] { - "Description": "Define dynamic entities using .NET attributes and configure them with the Fluent API in the ABP Low-Code System for advanced source-controlled model configuration." + "Description": "Define ABP Low-Code entities in C# with attributes and refine them with the Fluent API. This page documents the developer-facing code-first surface, including entity/property attributes, validation behavior, attachments, file/image options, enums, and fluent configuration helpers." } ``` # Attributes & Fluent API -> **Preview:** Attributes and Fluent API configuration for the Low-Code System are preview APIs. Prefer the designer for normal modeling work, and review release notes before relying on these APIs in long-lived integrations. +> **Preview:** Attributes and Fluent API configuration for the Low-Code System are preview APIs. Prefer the designer for day-to-day modeling work, and review release notes before relying on these APIs in long-lived integrations. -Use the [Low-Code Designer](designer.md) for day-to-day entity, page, form, and filter work. C# attributes and the Fluent API are advanced configuration options for teams that need source-controlled model definitions, compile-time checking, or programmatic overrides. +Use the [Low-Code Designer](designer.md) for runtime pages, forms, filters, dashboards, menus, and page groups. The code-first surface documented here is for source-controlled model metadata: entity definitions, property metadata, enums, validation, attachments, relationships, and runtime overrides. + +## What This Surface Configures + +Attributes and Fluent API can define or override: + +* entity registration and display names +* parent-child entity structure +* default display properties for lookups +* property types, defaults, uniqueness, required state, and storage shape +* foreign keys to dynamic or reference entities +* file and image upload constraints +* entity attachments +* enum registration +* command interceptors +* cross-field validations + +They do **not** define page titles, field placements, tabs, form layouts, filters, dashboards, menus, or page groups. Configure those in the designer or in [Model Descriptor Files](model-json.md). ## Quick Start -### Step 1: Define an Entity +### Step 1: Define a Dynamic Enum and Entity ````csharp -[DynamicEntity] -[DynamicEntityUI(PageTitle = "Products")] +[DynamicEnum] +public enum ProductStatus +{ + Draft = 0, + Active = 1, + Discontinued = 2 +} + +[DynamicEntity(DefaultDisplayPropertyName = nameof(Name))] +[DynamicEntityUI("Products")] +[DynamicEntityAttachments( + "application/pdf", + "image/png", + "image/jpeg", + MaxFileCount = 5, + MaxFileSizeBytes = 5 * 1024 * 1024 +)] public class Product : DynamicEntityBase { + [Required] + [StringLength(128)] [DynamicPropertyUnique] - public string Name { get; set; } + public string Name { get; set; } = null!; - [DynamicPropertyUI(DisplayName = "Unit Price")] + [Display(Name = "Unit Price")] + [DynamicPropertyType(EntityPropertyType.Money)] + [DynamicPropertyDefaultValue("0")] public decimal Price { get; set; } - public int StockCount { get; set; } - - public DateTime? ReleaseDate { get; set; } + public ProductStatus Status { get; set; } + + [DynamicForeignKey("Catalog.Categories.Category", "Name", ForeignAccess.View)] + public Guid? CategoryId { get; set; } + + [DynamicPropertyFileOptions( + "application/pdf", + MaxSizeBytes = 5 * 1024 * 1024 + )] + public string? SpecSheet { get; set; } + + [DynamicPropertyImageOptions( + "image/png", + "image/jpeg", + MaxWidth = 1024, + MaxHeight = 1024, + MaxSizeBytes = 2 * 1024 * 1024, + ResizeMode = "fit" + )] + public string? HeroImage { get; set; } + + [DynamicPropertyNotMapped] + public string? SearchSummary { get; set; } } ```` -### Step 2: Add Migration and Run +### Step 2: Register the Assembly and Initialize the Model -```bash -dotnet ef migrations add Added_Product -dotnet ef database update -``` +````csharp +AbpDynamicEntityConfig.SourceAssemblies.Add( + new DynamicEntityAssemblyInfo(typeof(MyDomainModule).Assembly) +); -After migrations and runtime startup, the React low-code runtime can render a Product management page with data grid, create/edit forms, search, sorting, filters, and pagination. +await DynamicModelManager.Instance.InitializeAsync(); +```` -### Step 3: Add Relationships +### Step 3: Apply Fluent Overrides When Needed ````csharp -[DynamicEntity] -[DynamicEntityUI(PageTitle = "Orders")] -public class Order : DynamicEntityBase -{ - [DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] - public Guid CustomerId { get; set; } - - public decimal TotalAmount { get; set; } - public bool IsDelivered { get; set; } -} - -[DynamicEntity(Parent = "MyApp.Orders.Order")] -public class OrderLine : DynamicEntityBase +AbpDynamicEntityConfig.EntityConfigurations.Configure(entity => { - [DynamicForeignKey("MyApp.Products.Product", "Name")] - public Guid ProductId { get; set; } - - public int Quantity { get; set; } - public decimal Amount { get; set; } -} + entity.WithDisplayName("Catalog Products"); + entity.EnableAttachments( + 10, + 5 * 1024 * 1024, + 20 * 1024 * 1024, + "application/pdf", + "image/png", + "image/jpeg" + ); + + entity.AddOrGetProperty("DiscountedPrice") + .AsMoney() + .WithDefaultValue("0"); + + entity.AddCrossFieldValidation( + "DiscountedPrice", + "Price", + EntityCrossFieldValidationOperators.LessThanOrEqual, + "Discounted price cannot exceed unit price." + ); + + entity.GetProperty("HeroImage").AsImage( + 2 * 1024 * 1024, + 1024, + 1024, + "fit", + "image/png", + "image/jpeg" + ); +}); ```` -The `Order` page now has a foreign key dropdown for Customer, and `OrderLine` is managed as a nested child inside the Order detail modal. - ## Three-Layer Configuration System -The Low-Code System uses a layered configuration model. From lowest to highest priority: +From lowest to highest priority: -1. **Code Layer** — C# classes with `[DynamicEntity]` and other attributes -2. **JSON Descriptor Layer** — source-controlled descriptor files under `_Dynamic` (see [Model Descriptor Files](model-json.md)) -3. **Fluent Layer** — `AbpDynamicEntityConfig.EntityConfigurations` +1. **Code layer**: C# classes with `[DynamicEntity]`, property attributes, and CLR validation attributes +2. **JSON descriptor layer**: source-controlled descriptor files under `_Dynamic` +3. **Fluent layer**: `AbpDynamicEntityConfig.EntityConfigurations` -A `DefaultLayer` runs last to fill in any missing values with conventions. +A `DefaultLayer` runs last to fill in conventions such as required validation for non-nullable properties. -> When the same entity or property is configured in multiple layers, the higher-priority layer wins. +> When the same value is set in multiple layers, the higher-priority layer wins. ## C# Attributes Reference -### `[DynamicEntity]` +### Entity-Level Attributes + +| Attribute | Use it for | Key members | +|-----------|------------|-------------| +| `[DynamicEntity]` | Marks a CLR class as a low-code entity | `Parent`, `DefaultDisplayPropertyName` | +| `[DynamicEntityUI]` | Sets the entity display name used by the model | `DisplayName` | +| `[DynamicEntityAttachments]` | Enables and limits entity attachments | `IsEnabled`, `MaxFileCount`, `MaxFileSizeBytes`, `MaxTotalSizeBytes`, `AllowedContentTypes` | +| `[DynamicEntityCommandInterceptor]` | Adds `Create` / `Update` / `Delete` JavaScript interceptors | `Name`, `Type`, `Javascript` | +| `[DynamicEnum]` | Registers a CLR enum for low-code enum usage | No properties | + +#### `[DynamicEntity]` -Marks a class as a dynamic entity. The entity name is derived from the class namespace and name. +Use `[DynamicEntity]` on any class that should participate in the low-code model. ````csharp -[DynamicEntity] +[DynamicEntity(DefaultDisplayPropertyName = nameof(Name))] public class Product : DynamicEntityBase { - public string Name { get; set; } - public decimal Price { get; set; } + public string Name { get; set; } = null!; } ```` -Use the `Parent` property for parent-child (master-detail) relationships: +`DefaultDisplayPropertyName` defaults to `"Id"`. Set it when lookups and relation labels should show another field such as `Name` or `Code`. + +For parent-child structures, set `Parent` by full entity name. The attribute also has a `Type` constructor overload when the parent CLR type is available: ````csharp -[DynamicEntity(Parent = "MyApp.Orders.Order")] +[DynamicEntity("Catalog.Orders.Order")] public class OrderLine : DynamicEntityBase { + [DynamicForeignKey("Catalog.Products.Product", "Name")] public Guid ProductId { get; set; } - public int Quantity { get; set; } } ```` -### `[DynamicEntityUI]` +When `Parent` is set and the child class does not already declare `Id`, the code layer adds that mapped `Guid` foreign-key property automatically. -Configures entity-level UI. Entities with `PageTitle` get a menu item and a dedicated page: +#### `[DynamicEntityUI]` + +`DynamicEntityUIAttribute` only sets the entity `DisplayName`: ````csharp [DynamicEntity] -[DynamicEntityUI(PageTitle = "Product Management")] +[DynamicEntityUI("Products")] public class Product : DynamicEntityBase { - // ... } ```` -### `[DynamicForeignKey]` +It does **not** configure page titles, form layouts, menu placement, dashboards, or other screen metadata. -Defines a foreign key relationship on a `Guid` property: +#### `[DynamicEntityAttachments]` -````csharp -[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] -public Guid CustomerId { get; set; } -```` - -| Parameter | Description | -|-----------|-------------| -| `entityName` | Full name of the target entity — can be a **dynamic entity** (e.g., `"MyApp.Customers.Customer"`) or a **[reference entity](reference-entities.md)** (e.g., `"Volo.Abp.Identity.IdentityUser"`) | -| `displayPropertyName` | Property to show in lookups | -| `access` | `ForeignAccess.None`, `ForeignAccess.View`, or `ForeignAccess.Edit` (see [Foreign Access](foreign-access.md)) | - -### `[DynamicPropertyUI]` - -Controls property visibility and behavior in the UI: +Use this when the entity itself should support attachments: ````csharp -[DynamicPropertyUI( - DisplayName = "Registration Number", - IsAvailableOnListing = true, - IsAvailableOnDataTableFiltering = true, - CreationFormAvailability = EntityPropertyUIFormAvailability.Hidden, - EditingFormAvailability = EntityPropertyUIFormAvailability.NotAvailable, - QuickLookOrder = 100 +[DynamicEntity] +[DynamicEntityAttachments( + "application/pdf", + "image/png", + MaxFileCount = 3, + MaxFileSizeBytes = 5 * 1024 * 1024, + MaxTotalSizeBytes = 15 * 1024 * 1024 )] -public string RegistrationNumber { get; set; } -```` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `DisplayName` | string | null | Custom label for the property | -| `IsAvailableOnListing` | bool | `true` | Show in data grid | -| `IsAvailableOnDataTableFiltering` | bool | `true` | Show in filter panel | -| `CreationFormAvailability` | enum | `Available` | Visibility on create form | -| `EditingFormAvailability` | enum | `Available` | Visibility on edit form | -| `QuickLookOrder` | int | `-2` | Order in quick-look panel | - -The quick-look panel shows a summary of the selected record: - -![Quick-look panel showing entity details](images/quick-look.png) - -### `[DynamicPropertyServerOnly]` - -Hides a property from API clients entirely. It is stored in the database but never returned to the client: - -````csharp -[DynamicPropertyServerOnly] -public string InternalNotes { get; set; } +public class Product : DynamicEntityBase +{ +} ```` -### `[DynamicPropertySetByClients]` - -Controls whether clients can set this property value. Useful for computed or server-assigned fields: - -````csharp -[DynamicPropertySetByClients(false)] -public string RegistrationNumber { get; set; } -```` +Set `IsEnabled = false` to disable entity attachments explicitly in code. -### `[DynamicPropertyUnique]` +#### `[DynamicEntityCommandInterceptor]` -Marks a property as requiring unique values across all records: - -````csharp -[DynamicPropertyUnique] -public string ProductCode { get; set; } -```` - -### `[DynamicEntityCommandInterceptor]` - -Defines JavaScript interceptors on a class for CRUD lifecycle hooks: +This attribute can be applied multiple times to the same entity: ````csharp [DynamicEntity] [DynamicEntityCommandInterceptor( "Create", InterceptorType.Pre, - "if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" + "if (!context.commandArgs.data['Name']) { globalError = 'Name is required.'; }" )] [DynamicEntityCommandInterceptor( "Delete", InterceptorType.Post, "context.log('Deleted: ' + context.commandArgs.entityId);" )] -public class Organization : DynamicEntityBase +public class Product : DynamicEntityBase { - public string Name { get; set; } + public string Name { get; set; } = null!; } ```` -> The `Name` parameter must be one of: `"Create"`, `"Update"`, or `"Delete"`. The `InterceptorType` can be `Pre`, `Post`, or `Replace`. When `Replace` is used, the default DB operation is skipped entirely and only the JavaScript handler runs. **`Replace-Create` must return the new entity's Id** (e.g. `return result.Id;` after `db.insert`). Multiple interceptors can be added to the same class (`AllowMultiple = true`). - -See [Interceptors](interceptors.md) for the full JavaScript context API. +Use `Create`, `Update`, or `Delete` for `Name`, and `Pre`, `Post`, or `Replace` for `Type`. See [Interceptors](interceptors.md) for the full scripting context and replace-mode behavior. -### `[DynamicEnum]` +#### `[DynamicEnum]` -Marks an enum for use in dynamic entity properties: +Decorate CLR enums that should be visible to the low-code model: ````csharp [DynamicEnum] -public enum OrganizationType +public enum ProductStatus { - Corporate = 0, - Enterprise = 1, - Startup = 2, - Consulting = 3 + Draft = 0, + Active = 1, + Discontinued = 2 } ```` -Reference in an entity: +Then use the enum directly on a dynamic entity property: ````csharp [DynamicEntity] -[DynamicEntityUI(PageTitle = "Organizations")] -public class Organization : DynamicEntityBase +public class Product : DynamicEntityBase { - public string Name { get; set; } - public OrganizationType OrganizationType { get; set; } + public ProductStatus Status { get; set; } } ```` -### Enum Localization - -Enum values can be localized using ABP's localization system. Add localization keys in the format `Enum:{EnumTypeName}.{ValueName}` to your localization JSON files: +Enum localization follows the usual ABP pattern: ```json { "culture": "en", "texts": { - "Enum:OrganizationType.Corporate": "Corporate", - "Enum:OrganizationType.Enterprise": "Enterprise", - "Enum:OrganizationType.Startup": "Startup", - "Enum:OrganizationType.Consulting": "Consulting" + "Enum:ProductStatus.Draft": "Draft", + "Enum:ProductStatus.Active": "Active", + "Enum:ProductStatus.Discontinued": "Discontinued" } } ``` -The React runtime automatically uses these localization keys for enum dropdowns and display values. If no localization key is found, the enum member name is used as-is. +### Property-Level Attributes -## Fluent API +| Attribute | Use it for | Key members | +|-----------|------------|-------------| +| [DynamicPropertyUI] | Sets the property display name | `DisplayName` | +| [Display(Name = ...)] | Sets the property display name with standard .NET metadata | `Name` | +| [DynamicPropertyType] | Overrides the inferred low-code type | `Type` | +| [DynamicPropertyDefaultValue] | Sets the descriptor default value | `DefaultValue` | +| [DynamicPropertyNotMapped] | Stores the property in the data dictionary instead of a dedicated DB field | No properties | +| [DynamicForeignKey] | Configures a lookup relation | `EntityName`, `DisplayPropertyName`, `Access`, `DependsOnPropertyName`, `DependsOnFilterPropertyName` | +| [DynamicPropertySetByClients] | Controls whether clients may write the field | `Allow` | +| [DynamicPropertyServerOnly] | Hides the field from clients entirely | No properties | +| [DynamicPropertyUnique] | Marks the field as unique | No properties | +| [DynamicPropertyFileOptions] | Marks the field as a file and applies upload constraints | `MaxSizeBytes`, `AllowedContentTypes` | +| [DynamicPropertyImageOptions] | Marks the field as an image and applies upload and resize constraints | `MaxSizeBytes`, `MaxWidth`, `MaxHeight`, `ResizeMode`, `AllowedContentTypes` | -The Fluent API has the **highest priority** in the configuration system. Use `AbpDynamicEntityConfig.EntityConfigurations` to override any attribute or JSON setting programmatically. +#### Display Names: `[DynamicPropertyUI]` and `[Display]` -### Basic Usage - -Configure in startup initialization code (for example `MyAppLowCodeInitializer`): +Both attributes write to the same `DisplayName` field in the descriptor: ````csharp -public static class MyAppLowCodeInitializer -{ - private static readonly AsyncOneTimeRunner Runner = new(); - - public static async Task InitializeAsync() - { - await Runner.RunAsync(async () => - { - AbpDynamicEntityConfig.EntityConfigurations.Configure( - "MyApp.Products.Product", - entity => - { - entity.DefaultDisplayPropertyName = "Name"; - - var priceProperty = entity.AddOrGetProperty("Price"); - priceProperty.AsRequired(); - priceProperty.UI = new EntityPropertyUIDescriptor - { - DisplayName = "Unit Price", - CreationFormAvailability = EntityPropertyUIFormAvailability.Available - }; - - entity.AddOrGetProperty("InternalNotes").AsServerOnly(); - } - ); +[DynamicPropertyUI(DisplayName = "SKU")] +public string Code { get; set; } = null!; - await DynamicModelManager.Instance.InitializeAsync(); - }); - } -} +[Display(Name = "Unit Price")] +public decimal Price { get; set; } ```` -You can also use the generic overload with a type parameter: +If both are present on the same property, `[Display(Name = ...)]` takes precedence. + +Like `DynamicEntityUI`, `DynamicPropertyUI` does **not** control list visibility, form placement, filters, quick-look order, tabs, or other runtime screen layout details. Those belong to page and form descriptors. + +#### Type, Storage, Relations, and Upload Options ````csharp -AbpDynamicEntityConfig.EntityConfigurations.Configure(entity => +public class Product : DynamicEntityBase { - entity.DefaultDisplayPropertyName = "Name"; -}); + [DynamicPropertyType(EntityPropertyType.Money)] + [DynamicPropertyDefaultValue("0")] + public decimal Price { get; set; } + + [DynamicForeignKey( + "Catalog.Categories.Category", + "Name", + ForeignAccess.View + )] + public Guid? CategoryId { get; set; } + + [DynamicForeignKey( + "Catalog.Products.Product", + "Name", + ForeignAccess.View, + DependsOnPropertyName = nameof(CategoryId), + DependsOnFilterPropertyName = "CategoryId" + )] + public Guid? RelatedProductId { get; set; } + + [DynamicPropertyNotMapped] + public string? SearchSummary { get; set; } + + [DynamicPropertySetByClients(false)] + public string? GeneratedCode { get; set; } + + [DynamicPropertyServerOnly] + public string? InternalNotes { get; set; } + + [DynamicPropertyUnique] + public string Code { get; set; } = null!; + + [DynamicPropertyFileOptions( + "application/pdf", + MaxSizeBytes = 5 * 1024 * 1024 + )] + public string? SpecSheet { get; set; } + + [DynamicPropertyImageOptions( + "image/png", + "image/jpeg", + MaxWidth = 1024, + MaxHeight = 1024, + MaxSizeBytes = 2 * 1024 * 1024, + ResizeMode = "fit" + )] + public string? HeroImage { get; set; } +} ```` -### Entity Configuration +`DynamicPropertyFileOptions` sets `Type = File`. `DynamicPropertyImageOptions` sets `Type = Image`. Use them instead of setting file or image types manually when you also need upload constraints. -The `Configure` method provides an `EntityDescriptor` instance. You can set its properties directly: +For image fields, `ResizeMode` currently supports these values: -| Property / Method | Description | -|--------|-------------| -| `DefaultDisplayPropertyName` | Set the display property for lookups | -| `Parent` | Set parent entity name for nesting | -| `UI` | Set entity-level UI (`EntityUIDescriptor` with `PageTitle`) | -| `AddOrGetProperty(name)` | Get or create a property descriptor for configuration | -| `FindProperty(name)` | Find a property descriptor by name (returns `null` if not found) | -| `GetProperty(name)` | Get a property descriptor by name (throws if not found) | -| `Interceptors` | List of `CommandInterceptorDescriptor` — add interceptors directly | +* `fit`: preserves aspect ratio and scales the image down so it fits within `MaxWidth` and `MaxHeight` +* `fill`: crops from the center and scales the image to fill the target box; use it when both `MaxWidth` and `MaxHeight` are set -### Property Configuration +If `ResizeMode = "fill"` is configured without both `MaxWidth` and `MaxHeight`, the current React runtime falls back to `fit`. -`AddOrGetProperty` returns an `EntityPropertyDescriptor`. Configure it using direct property assignment and extension methods: +Resize is currently applied client-side by the React low-code runtime form before upload. Backend upload endpoints validate file size and content type, then store the stream as-is. If you upload images through scripting or direct API/backend calls instead of the React runtime form, no automatic resize is applied on the server. -| Property / Extension Method | Description | -|--------|-------------| -| `.AsRequired(bool)` | Mark as required (extension method, returns the descriptor for chaining) | -| `.AsServerOnly(bool)` | Hide from clients (extension method, returns the descriptor for chaining) | -| `.MapToDbField(bool)` | Control if property is stored in DB (extension method, returns the descriptor for chaining) | -| `.IsUnique` | Set to `true` to mark as unique | -| `.AllowSetByClients` | Set to `false` to prevent client writes | -| `.ForeignKey` | Set a `ForeignKeyDescriptor` to configure foreign key relationship | -| `.UI` | Set an `EntityPropertyUIDescriptor` to configure property UI | +## Standard .NET Attributes and Required Inference -### Chaining Extension Methods +Code-defined low-code properties also consume standard .NET metadata: -The extension methods `AsRequired()`, `AsServerOnly()`, and `MapToDbField()` return the property descriptor, enabling fluent chaining: +* `[Display(Name = ...)]` sets the descriptor `DisplayName` +* any `ValidationAttribute` on the CLR property is copied into the descriptor +* `[Required]` and non-nullable types contribute to `IsRequired` -````csharp -entity.AddOrGetProperty("InternalNotes") - .AsServerOnly() - .AsRequired() - .MapToDbField(); -```` +The required-state rules are: -### Configuring Foreign Keys +* `[Required]` always makes the property required +* non-nullable value types such as `int`, `Guid`, `DateTime`, `decimal`, and `bool` are treated as required +* nullable value types such as `int?` and `Guid?` are optional +* nullable reference types such as `string?` are optional +* non-nullable reference types without an explicit `[Required]` stay optional for backward compatibility -````csharp -AbpDynamicEntityConfig.EntityConfigurations.Configure( - "MyApp.Orders.Order", - entity => - { - var customerIdProperty = entity.AddOrGetProperty("CustomerId"); - customerIdProperty.ForeignKey = new ForeignKeyDescriptor - { - EntityName = "MyApp.Customers.Customer", - DisplayPropertyName = "Name", - Access = ForeignAccess.Edit - }; - } -); -```` +For source-controlled low-code models, the built-in validation set that round-trips cleanly through the descriptor pipeline is: -### Adding Interceptors +* `[Required]` +* `[StringLength]` +* `[MaxLength]` +* `[MinLength]` +* `[Range]` +* `[EmailAddress]` +* `[Phone]` +* `[Url]` +* `[CreditCard]` +* `[RegularExpression]` + +Example: ````csharp -entity.Interceptors.Add(new CommandInterceptorDescriptor("Create") +public class Product : DynamicEntityBase { - Type = InterceptorType.Pre, - Javascript = "if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" -}); + [Required] + [StringLength(128, MinimumLength = 3)] + public string Name { get; set; } = null!; + + [Range(0, 100000)] + public decimal Price { get; set; } + + [EmailAddress] + public string? ContactEmail { get; set; } +} ```` -## Assembly Registration +For advanced validation scenarios, JSON `validators` and cross-field validations are still the better fit than custom CLR validation attributes. + +## Fluent API + +The Fluent API is the highest-priority model layer. Use it when you need runtime overrides, source-controlled patches over descriptor files, or descriptor-only properties without adding CLR members. + +### Registration and Initialization -Register assemblies containing `[DynamicEntity]` classes in startup initialization code: +Register source assemblies before initialization: ````csharp AbpDynamicEntityConfig.SourceAssemblies.Add( new DynamicEntityAssemblyInfo( typeof(MyDomainModule).Assembly, - rootNamespace: "MyApp", - projectRootPath: sourcePath // For descriptor hot-reload + rootNamespace: "Catalog", + projectRootPath: sourcePath ) ); -```` -| Parameter | Description | -|-----------|-------------| -| `assembly` | The assembly containing `[DynamicEntity]` classes and/or descriptor metadata | -| `rootNamespace` | Root namespace for the assembly (used for embedded resource lookup) | -| `projectRootPath` | Path to the Domain project source folder (enables descriptor hot-reload in development) | +await DynamicModelManager.Instance.InitializeAsync(); +```` -You can also register entity types directly: +You can also register specific types directly: ````csharp AbpDynamicEntityConfig.DynamicEntityTypes.Add(typeof(Product)); -AbpDynamicEntityConfig.DynamicEnumTypes.Add(typeof(OrganizationType)); +AbpDynamicEntityConfig.DynamicEnumTypes.Add(typeof(ProductStatus)); ```` -## Combining with JSON Descriptors - -Attributes and JSON descriptors work together seamlessly. A common pattern: - -1. **Define core entities** with C# attributes (compile-time safety) -2. **Add additional entities** via descriptor files (no recompilation needed) -3. **Fine-tune configuration** with Fluent API (overrides everything) - -The three-layer system merges all definitions: - -``` -Fluent API (highest) > JSON descriptors > Code (Attributes) > Defaults (lowest) -``` - -For example, if an attribute sets `[DynamicPropertyUnique]` and a descriptor sets `"isUnique": false`, the JSON value wins because the JSON descriptor layer has higher priority than the Code layer. - -## End-to-End Example - -A complete e-commerce-style entity setup: +Use the generic overload when a CLR type exists, or the string overload for descriptor-only entities: ````csharp -// Enum -[DynamicEnum] -public enum OrderStatus -{ - Pending = 0, - Processing = 1, - Shipped = 2, - Delivered = 3 -} - -// Customer entity -[DynamicEntity] -[DynamicEntityUI(PageTitle = "Customers")] -public class Customer : DynamicEntityBase -{ - [DynamicPropertyUnique] - public string Name { get; set; } - - [DynamicPropertyUI(DisplayName = "Phone Number", QuickLookOrder = 100)] - public string Telephone { get; set; } - - [DynamicForeignKey("Volo.Abp.Identity.IdentityUser", "UserName")] - public Guid? UserId { get; set; } - - [DynamicPropertyServerOnly] - public string InternalNotes { get; set; } -} - -// Product entity -[DynamicEntity] -[DynamicEntityUI(PageTitle = "Products")] -public class Product : DynamicEntityBase +AbpDynamicEntityConfig.EntityConfigurations.Configure(entity => { - [DynamicPropertyUnique] - public string Name { get; set; } + entity.WithDisplayName("Products"); +}); - public decimal Price { get; set; } - public int StockCount { get; set; } -} +AbpDynamicEntityConfig.EntityConfigurations.Configure( + "Catalog.Products.Product", + entity => + { + entity.WithDisplayName("Products"); + } +); +```` -// Order entity with child OrderLine -[DynamicEntity] -[DynamicEntityUI(PageTitle = "Orders")] -[DynamicEntityCommandInterceptor( - "Update", - InterceptorType.Pre, - @"if(context.commandArgs.data['IsDelivered']) { - if(!context.currentUser.roles.includes('admin')) { - globalError = 'Only admins can mark as delivered!'; - } - }" -)] -public class Order : DynamicEntityBase -{ - [DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] - public Guid CustomerId { get; set; } +### `EntityDescriptor` Surface + +The `Configure(...)` callback gives you an `EntityDescriptor`. + +| Member | What it controls | +|--------|-------------------| +| `DisplayName` | Entity display name | +| `DefaultDisplayPropertyName` | Field used for lookups and relation labels | +| `Parent` | Parent entity name for child entities | +| `Attachments` | Raw `EntityAttachmentDescriptor` access | +| `CrossFieldValidations` | Raw cross-field validation list | +| `Interceptors` | Raw interceptor list | +| `Properties` | Full property descriptor list | +| `AddOrGetProperty(name)` | Returns an existing property or creates a new descriptor | +| `FindProperty(name)` | Returns the property or `null` | +| `GetProperty(name)` | Returns the property or throws | +| `WithDisplayName(string)` | Fluent helper for `DisplayName` | +| `EnableAttachments(...)` | Fluent helper for attachment configuration | +| `DisableAttachments()` | Explicitly disables attachments | +| `AddCrossFieldValidation(...)` | Adds an entity-level cross-field validation | + +### `EntityPropertyDescriptor` Surface + +`AddOrGetProperty(...)`, `FindProperty(...)`, and `GetProperty(...)` work with `EntityPropertyDescriptor`. + +#### Direct members + +| Member | What it controls | +|--------|-------------------| +| `DisplayName` | Property display label | +| `Type` | Low-code property type | +| `IsRequired` | Required state | +| `IsMappedToDbField` | Dedicated DB field vs data dictionary storage | +| `DefaultValue` | Default value stored in the descriptor | +| `IsUnique` | Uniqueness | +| `AllowSetByClients` | Client write access | +| `ServerOnly` | Hide from clients entirely | +| `ForeignKey` | Foreign key descriptor | +| `EnumType` | Enum CLR type name for descriptor-only enum fields | +| `FileMaxSizeBytes` | File/image max size | +| `FileAllowedContentTypes` | Allowed MIME types for file/image fields | +| `ImageMaxWidth` | Image width constraint | +| `ImageMaxHeight` | Image height constraint | +| `ImageResizeMode` | Resize strategy | +| `ValidationAttributes` | Raw validation descriptors | + +#### Extension methods + +| Method | What it does | +|--------|---------------| +| `MapToDbField(bool isMappedToDbField = true)` | Sets `IsMappedToDbField` | +| `AsType(EntityPropertyType type)` | Sets `Type` | +| `AsMoney()` | Sets `Type = Money` | +| `AsFile(long? maxSizeBytes = null, params string[] allowedContentTypes)` | Sets `Type = File` and file constraints | +| `AsImage(long? maxSizeBytes = null, int? maxWidth = null, int? maxHeight = null, string? resizeMode = null, params string[] allowedContentTypes)` | Sets `Type = Image` and image constraints | +| `WithDefaultValue(string? defaultValue)` | Sets `DefaultValue` | +| `WithEnumType(string enumType)` | Sets `Type = Enum` and `EnumType` | +| `WithForeignKey(string entityName, string? displayPropertyName = null, ForeignAccess access = ForeignAccess.None, string? dependsOnPropertyName = null, string? dependsOnFilterPropertyName = null)` | Configures the relation in one call | +| `AsServerOnly(bool serverOnly = true)` | Sets `ServerOnly` | +| `AsRequired(bool isRequired = true)` | Sets `IsRequired` | + +### Fluent API Example - public decimal TotalAmount { get; set; } - public bool IsDelivered { get; set; } - public OrderStatus Status { get; set; } -} +````csharp +AbpDynamicEntityConfig.EntityConfigurations.Configure( + "Catalog.Products.Product", + entity => + { + entity.WithDisplayName("Products"); + entity.DefaultDisplayPropertyName = "Name"; + entity.EnableAttachments( + 5, + 5 * 1024 * 1024, + 20 * 1024 * 1024, + "application/pdf", + "image/png" + ); + + entity.AddOrGetProperty("Code") + .AsRequired() + .WithDefaultValue("P-"); + + entity.GetProperty("Code").DisplayName = "SKU"; + + entity.AddOrGetProperty("Price") + .AsMoney() + .AsRequired(); + + entity.GetProperty("Price").DisplayName = "Unit Price"; + + entity.AddOrGetProperty("Status") + .WithEnumType("Catalog.Products.ProductStatus"); + + entity.AddOrGetProperty("CategoryId") + .WithForeignKey("Catalog.Categories.Category", "Name", ForeignAccess.View); + + entity.AddOrGetProperty("HeroImage") + .AsImage( + 2 * 1024 * 1024, + 1024, + 1024, + "fit", + "image/png", + "image/jpeg" + ); -[DynamicEntity(Parent = "MyApp.Orders.Order")] -public class OrderLine : DynamicEntityBase -{ - [DynamicForeignKey("MyApp.Products.Product", "Name")] - public Guid ProductId { get; set; } + entity.AddOrGetProperty("InternalNotes") + .AsServerOnly(); - public int Quantity { get; set; } - public decimal Amount { get; set; } -} -```` + entity.AddOrGetProperty("DiscountedPrice") + .AsMoney() + .WithDefaultValue("0"); -Register everything in startup initialization code: + entity.AddCrossFieldValidation( + "DiscountedPrice", + "Price", + EntityCrossFieldValidationOperators.LessThanOrEqual, + "Discounted price cannot exceed unit price." + ); -````csharp -public static class MyAppLowCodeInitializer -{ - private static readonly AsyncOneTimeRunner Runner = new(); - - public static async Task InitializeAsync() - { - await Runner.RunAsync(async () => + entity.Interceptors.Add(new CommandInterceptorDescriptor("Create") { - // Reference existing ABP entities - AbpDynamicEntityConfig.ReferencedEntityList.Add("UserName"); - - // Register assembly - AbpDynamicEntityConfig.SourceAssemblies.Add( - new DynamicEntityAssemblyInfo(typeof(MyDomainModule).Assembly) - ); - - // Initialize - await DynamicModelManager.Instance.InitializeAsync(); + Type = InterceptorType.Pre, + Javascript = "if (!context.commandArgs.data['Name']) { globalError = 'Name is required.'; }" }); } -} +); ```` -Configure your DbContext to implement `IDbContextWithDynamicEntities`: +### Fluent API Notes + +* Use attributes when the field already exists as a CLR property. +* Use Fluent API when you need to override descriptor values without touching the CLR class. +* Use Fluent API when you need descriptor-only properties or cross-field validations. +* Use JSON descriptors or the designer for page/form/filter/dashboard/menu/page-group metadata. + +## EF Core Setup + +Your DbContext should implement `IDbContextWithDynamicEntities` and call both `ConfigureDynamicEntities()` and `ConfigureLowCode()`. +Call `ConfigureDynamicEntities()` before `base.OnModelCreating(builder)` so dynamic entity mappings are available when ABP applies the base model conventions: ````csharp -public class MyAppDbContext : AbpDbContext, IDbContextWithDynamicEntities +public class CatalogDbContext : AbpDbContext, IDbContextWithDynamicEntities { - // ... constructors and DbSets ... - protected override void OnModelCreating(ModelBuilder builder) { builder.ConfigureDynamicEntities(); + base.OnModelCreating(builder); + + builder.ConfigureLowCode(); } } ```` -Configure your DbContextFactory for EF Core CLI commands: +Run low-code initialization before EF Core design-time migrations so the model is complete when migrations are generated. -````csharp -public class MyAppDbContextFactory : IDesignTimeDbContextFactory -{ - public MyAppDbContext CreateDbContext(string[] args) - { - var configuration = BuildConfiguration(); - - MyAppEfCoreEntityExtensionMappings.Configure(); - - // ----- Ensure Low-Code system is initialized before running migrations --- - LowCodeEfCoreTypeBuilderExtensions.Configure(); - AsyncHelper.RunSync(MyAppLowCodeInitializer.InitializeAsync); - // ------------------------------- - - var builder = new DbContextOptionsBuilder() - .UseSqlServer(configuration.GetConnectionString("Default")); - - return new MyAppDbContext(builder.Options); - } - - // ... BuildConfiguration method ... -} -```` +## Layer Precedence + +The final model is merged in this order: + +```text +Fluent API > JSON descriptors > Code attributes and CLR metadata > Defaults +``` + +For example: -This gives you four auto-generated pages (Customers, Products, Orders with nested OrderLines), complete with permissions, menu items, foreign key lookups, and interceptor-based business rules. +* if `[DynamicPropertyUnique]` marks a field as unique but a descriptor sets `"isUnique": false`, the descriptor wins +* if JSON sets a display name and Fluent API later changes `DisplayName`, the Fluent API wins ## See Also * [Model Descriptor Files](model-json.md) +* [Code Integration](code-integration.md) * [Reference Entities](reference-entities.md) +* [Foreign Access](foreign-access.md) * [Interceptors](interceptors.md) diff --git a/docs/en/low-code/health.md b/docs/en/low-code/health.md new file mode 100644 index 0000000000..dd209d3b7a --- /dev/null +++ b/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 -- --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) diff --git a/docs/en/low-code/images/designer-entity.png b/docs/en/low-code/images/designer-entity.png index f321f0f4a9..e1d17d4e16 100644 Binary files a/docs/en/low-code/images/designer-entity.png and b/docs/en/low-code/images/designer-entity.png differ diff --git a/docs/en/low-code/images/designer-forms.png b/docs/en/low-code/images/designer-forms.png index 230c7c6982..1252a88939 100644 Binary files a/docs/en/low-code/images/designer-forms.png and b/docs/en/low-code/images/designer-forms.png differ diff --git a/docs/en/low-code/images/designer-overview.png b/docs/en/low-code/images/designer-overview.png index f75ced1479..1c700f9336 100644 Binary files a/docs/en/low-code/images/designer-overview.png and b/docs/en/low-code/images/designer-overview.png differ diff --git a/docs/en/low-code/images/designer-page-filters.png b/docs/en/low-code/images/designer-page-filters.png index 647df1e064..7d252046bf 100644 Binary files a/docs/en/low-code/images/designer-page-filters.png and b/docs/en/low-code/images/designer-page-filters.png differ diff --git a/docs/en/low-code/images/designer-properties.png b/docs/en/low-code/images/designer-properties.png index 8d9cdf8569..4ffc8106c0 100644 Binary files a/docs/en/low-code/images/designer-properties.png and b/docs/en/low-code/images/designer-properties.png differ diff --git a/docs/en/low-code/images/runtime-create-form.png b/docs/en/low-code/images/runtime-create-form.png index 258bab6370..1574da2015 100644 Binary files a/docs/en/low-code/images/runtime-create-form.png and b/docs/en/low-code/images/runtime-create-form.png differ diff --git a/docs/en/low-code/images/runtime-dashboard.png b/docs/en/low-code/images/runtime-dashboard.png new file mode 100644 index 0000000000..a93a683d17 Binary files /dev/null and b/docs/en/low-code/images/runtime-dashboard.png differ diff --git a/docs/en/low-code/images/runtime-data-grid.png b/docs/en/low-code/images/runtime-data-grid.png index c47cc03014..6c0e731f13 100644 Binary files a/docs/en/low-code/images/runtime-data-grid.png and b/docs/en/low-code/images/runtime-data-grid.png differ diff --git a/docs/en/low-code/images/runtime-filters-has-value.png b/docs/en/low-code/images/runtime-filters-has-value.png index dfc7b30788..e145e220ae 100644 Binary files a/docs/en/low-code/images/runtime-filters-has-value.png and b/docs/en/low-code/images/runtime-filters-has-value.png differ diff --git a/docs/en/low-code/images/runtime-filters.png b/docs/en/low-code/images/runtime-filters.png index e63b8dcbea..f2d8532ded 100644 Binary files a/docs/en/low-code/images/runtime-filters.png and b/docs/en/low-code/images/runtime-filters.png differ diff --git a/docs/en/low-code/images/studio-low-code-step.png b/docs/en/low-code/images/studio-low-code-step.png new file mode 100644 index 0000000000..72e31144a4 Binary files /dev/null and b/docs/en/low-code/images/studio-low-code-step.png differ diff --git a/docs/en/low-code/index.md b/docs/en/low-code/index.md index 0960303ddb..b428132003 100644 --- a/docs/en/low-code/index.md +++ b/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/.Domain/_Dynamic/` +* Single-layer solutions: `.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:/dynamic/ ``` +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 -- --check-lowcode-model-files +``` + +The generated startup project accepts `--model-directory ` 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) diff --git a/docs/en/low-code/mcp.md b/docs/en/low-code/mcp.md new file mode 100644 index 0000000000..cf0f7a7817 --- /dev/null +++ b/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:/mcp/low-code-designer +``` + +Connection details: + +* **Transport:** Streamable HTTP / HTTP MCP transport +* **Authentication:** `Authorization: Bearer ` +* **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 ``. +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:/connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=LowCodeMcpClient" \ + -d "client_secret=" \ + -d "scope=" +``` + +Use the returned `access_token` as the bearer token in the MCP client configuration. + +```text +Authorization: Bearer +``` + +## 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:/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:/mcp/low-code-designer", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +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 `, 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) diff --git a/docs/en/low-code/model-json.md b/docs/en/low-code/model-json.md index 2c11503553..66b5c0486c 100644 --- a/docs/en/low-code/model-json.md +++ b/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 -- --check-lowcode-model-files +``` + +To validate a specific folder instead of the configured source assemblies, pass `--model-directory`: + +```bash +dotnet run --project -- --check-lowcode-model-files --model-directory "" +``` + +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) diff --git a/docs/en/low-code/page-groups.md b/docs/en/low-code/page-groups.md new file mode 100644 index 0000000000..90d80c7763 --- /dev/null +++ b/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) diff --git a/docs/en/low-code/react-runtime.md b/docs/en/low-code/react-runtime.md index 01e0e37ad9..98fa007ef1 100644 --- a/docs/en/low-code/react-runtime.md +++ b/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 | diff --git a/docs/en/low-code/reference-entities.md b/docs/en/low-code/reference-entities.md index 3e6475f258..f99500a8f1 100644 --- a/docs/en/low-code/reference-entities.md +++ b/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) diff --git a/docs/en/low-code/script-actions.md b/docs/en/low-code/script-actions.md index 197d59ed37..29785b5743 100644 --- a/docs/en/low-code/script-actions.md +++ b/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);" } ] } diff --git a/docs/en/low-code/scripting-api.md b/docs/en/low-code/scripting-api.md index dd5da7c067..1b9a421488 100644 --- a/docs/en/low-code/scripting-api.md +++ b/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 }); ``` diff --git a/docs/en/modules/openiddict-pro.md b/docs/en/modules/openiddict-pro.md index 94d8deb631..9d6cae0484 100644 --- a/docs/en/modules/openiddict-pro.md +++ b/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:/connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=InternalAutomationClient" \ + -d "client_secret=" \ + -d "scope=" +``` + +Use the returned `access_token` as a bearer token when calling protected APIs: + +```text +Authorization: Bearer +``` + +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.