diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 3273b09aee..32c47a832e 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -2145,6 +2145,10 @@ "text": "Reference Entities", "path": "low-code/reference-entities.md" }, + { + "text": "Code Integration", + "path": "low-code/code-integration.md" + }, { "text": "Foreign Access", "path": "low-code/foreign-access.md" 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 index 25d18adbde..9851932f15 100644 --- a/docs/en/low-code/dashboards.md +++ b/docs/en/low-code/dashboards.md @@ -7,7 +7,7 @@ # Dashboards -Dashboards are low-code pages that render charts, lists, and number widgets from low-code entity data. A dashboard is still a page, so it uses the normal page name, title, icon, order, group, and permission model, but its page type is `dashboard` and its page definition carries a nested `dashboard` payload. +Dashboards are low-code pages that render charts, lists, and number widgets from low-code entity data. A dashboard is still a page, so it uses the normal page name, title, icon, order, group, and permission model, but its page type is `dashboard` and its page definition carries a nested `dashboard` payload. The screenshot below shows an inventory-style dashboard from the demo app, and the JSON that follows is a simplified descriptor that uses the same layout concepts. The runtime below was generated from a low-code dashboard page definition: @@ -46,50 +46,47 @@ At runtime, the React UI groups those flat visualizations into rendered rows. Th ```json { - "name": "sales-dashboard", - "title": "Sales Dashboard", + "name": "inventory-overview", + "title": "Inventory Overview", "type": "dashboard", - "group": "analytics", + "group": "inventory", "dashboard": { - "description": "Operational sales view", - "globalFilters": [ - { "type": "dateRange" } - ], + "description": "Operational inventory view", "visualizations": [ { - "name": "sales-by-status", - "type": "chart", - "title": "Sales by Status", - "row": 0, - "order": 0, - "width": 2, - "entityName": "Acme.Sales.Order", - "chart": { - "chartType": "bar", - "xAxis": { "property": "Status" }, - "yAxis": [ - { "aggregation": "count", "label": "Orders" } - ] - } - }, - { - "name": "totals", + "name": "product-count", "type": "numberContainer", - "title": "Totals", - "row": 1, + "title": "Product Count", + "row": 0, "order": 0, - "width": 2, + "width": 1, "numberContainer": { "items": [ { - "name": "order-count", - "title": "Order Count", - "entityName": "Acme.Sales.Order", + "name": "total-products", + "title": "Total Products", + "entityName": "Acme.Catalog.Product", "aggregation": "count", "format": "number" } ] } + }, + { + "name": "stock-by-product", + "type": "chart", + "title": "Stock by Product", + "row": 0, + "order": 1, + "width": 1, + "entityName": "Acme.Catalog.Product", + "chart": { + "chartType": "bar", + "xAxis": { "property": "Name" }, + "yAxis": [ + { "aggregation": "sum", "property": "StockCount", "label": "Stock" } + ] + } } ] } @@ -136,6 +133,8 @@ Number containers hold one or more number items. Each item can define: * entity-specific filters and global date filter linkage * click-through behavior +The sample descriptor above combines a number container and a chart in the same `Inventory Overview` page. + ## Filters and Interactivity Dashboards support three filter layers: diff --git a/docs/en/low-code/designer.md b/docs/en/low-code/designer.md index cdc259f2c9..ba24034906 100644 --- a/docs/en/low-code/designer.md +++ b/docs/en/low-code/designer.md @@ -109,7 +109,7 @@ For example, this form defines three fields and places those same field IDs into "fields": [ { "id": "name", "label": "Name", "type": "text", "binding": "Name" }, { "id": "price", "label": "Price", "type": "money", "binding": "Price" }, - { "id": "is-active", "label": "Active", "type": "checkbox", "binding": "IsActive" } + { "id": "stock-count", "label": "Stock Count", "type": "number", "binding": "StockCount" } ], "layout": { "tabs": [ @@ -119,7 +119,7 @@ For example, this form defines three fields and places those same field IDs into "fields": [ { "fieldId": "name", "row": 0, "colSpan": 4 }, { "fieldId": "price", "row": 1, "colSpan": 2 }, - { "fieldId": "is-active", "row": 1, "colSpan": 2 } + { "fieldId": "stock-count", "row": 1, "colSpan": 2 } ] } ] diff --git a/docs/en/low-code/fluent-api.md b/docs/en/low-code/fluent-api.md index 74c0caf118..92cbf70b79 100644 --- a/docs/en/low-code/fluent-api.md +++ b/docs/en/low-code/fluent-api.md @@ -571,5 +571,6 @@ This gives you four auto-generated pages (Customers, Products, Orders with neste ## See Also * [Model Descriptor Files](model-json.md) +* [Code Integration](code-integration.md) * [Reference Entities](reference-entities.md) * [Interceptors](interceptors.md) diff --git a/docs/en/low-code/images/designer-forms.png b/docs/en/low-code/images/designer-forms.png index 230c7c6982..2b16cef3f6 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-page-filters.png b/docs/en/low-code/images/designer-page-filters.png index 647df1e064..4e2e1366d3 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/runtime-create-form.png b/docs/en/low-code/images/runtime-create-form.png index a8ffdc8f18..477fb40657 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 index 48ea16dfb3..dc11f77ea0 100644 Binary files a/docs/en/low-code/images/runtime-dashboard.png 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 75c87f269e..d11b2b021a 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..834b3f27fe 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..1df4e1a896 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/index.md b/docs/en/low-code/index.md index a3db5f57b4..00d47bbbad 100644 --- a/docs/en/low-code/index.md +++ b/docs/en/low-code/index.md @@ -129,11 +129,11 @@ React low-code filters are type-aware. The runtime shows only operators that mak * Numeric fields support equals, comparison, between, and has value. * Date fields use date-friendly labels such as on, after, before, and between. * Boolean fields use an `All / Yes / No` value selector. -* File and image fields use `Has value` with an `All / Yes / No` value selector. +* File and image fields use the same `All / Yes / No` selector behind a `Has value` filter. -`All` means no filter is applied. `Yes` maps to non-empty values. `No` maps to empty values. +In the current screenshot set, the dropdown below is shown on the `Active` boolean filter. The same three-value selector is also used by `Has value` filters on file and image fields. -![Has value filter options](images/runtime-filters-has-value.png) +![Yes/No filter options](images/runtime-filters-has-value.png) ## Export @@ -180,6 +180,7 @@ The designer stores and reads the same descriptor metadata described in the refe | [Attributes & Fluent API](fluent-api.md) | Source-controlled C# metadata and runtime overrides | | [Model Descriptor Files](model-json.md) | JSON descriptor files and public descriptor schemas used by the designer and runtime | | [Reference Entities](reference-entities.md) | Lookups to existing entities such as Identity users | +| [Code Integration](code-integration.md) | Moving between low-code models and regular C# code in both directions | | [Foreign Access](foreign-access.md) | Access to related dynamic entities through relations | | [Interceptors](interceptors.md) | JavaScript lifecycle logic for CRUD operations | | [Custom Endpoints](custom-endpoints.md) | JavaScript-backed REST endpoints | @@ -205,4 +206,5 @@ The generated pages are powered by these services: * [Dashboards](dashboards.md) * [Page Groups](page-groups.md) * [MCP Integration](mcp.md) +* [Code Integration](code-integration.md) * [Model Descriptor Files](model-json.md) diff --git a/docs/en/low-code/mcp.md b/docs/en/low-code/mcp.md index 19a1c2ed53..29c17ee779 100644 --- a/docs/en/low-code/mcp.md +++ b/docs/en/low-code/mcp.md @@ -21,6 +21,93 @@ The MCP surface is **runtime-only**: Use [Model Descriptor Files](model-json.md) when you need source-controlled descriptors. Use MCP when an agent or automation needs safe, structured changes to the runtime model that the Designer can immediately show. +## Tool Reference + +All tools below work against the **runtime database-backed** model only. + +### Core Workflow Tools + +| Tool | Use it for | +|------|------------| +| `lowcode_designer_get_capabilities` | Read the server's runtime-only scope, write rules, recommended workflow, and example mutation targets | +| `lowcode_designer_get_mutation_metadata` | Fetch the current `concurrencyStamp` and the valid semantic target tree before planning a write | +| `lowcode_designer_validate_mutations` | Dry-run a mutation batch and get structured validation feedback without writing | +| `lowcode_designer_apply_mutations` | Apply a validated mutation batch to the runtime model; this is the **only write tool** | +| `lowcode_designer_get_health_snapshot` | Re-check the full runtime model after changes to catch broken references or invalid layouts | + +### Entity and Enum Tools + +| Tool | Use it for | +|------|------------| +| `lowcode_designer_get_entities` | List runtime entities; optionally include configured reference entities | +| `lowcode_designer_get_entity` | Read one entity with properties, validators, interceptors, and child metadata | +| `lowcode_designer_get_entity_delete_impact` | Review references that would break or change if an entity is removed | +| `lowcode_designer_get_enum_types` | List available enum types | +| `lowcode_designer_get_enum` | Read one enum and its values | +| `lowcode_designer_get_enum_delete_impact` | Review what depends on an enum before removing it | + +### Page, Form, and Permission Tools + +| Tool | Use it for | +|------|------------| +| `lowcode_designer_get_page_groups` | List page groups used for runtime navigation folders | +| `lowcode_designer_get_pages` | List pages across page types, including dashboard pages | +| `lowcode_designer_get_page` | Read one page descriptor, including columns, filters, dashboard config, and form links | +| `lowcode_designer_get_page_permission_config` | Read the page's permission bindings before changing access rules | +| `lowcode_designer_get_forms` | List forms | +| `lowcode_designer_get_form` | Read one form with fields, flat layout placements, validations, and rules | +| `lowcode_designer_get_form_delete_impact` | Review what depends on a form before removing it | +| `lowcode_designer_get_permissions` | List runtime low-code permission definitions | +| `lowcode_designer_get_permission_delete_impact` | Review where a permission is referenced before removing it | + +### Script and Action Tools + +| Tool | Use it for | +|------|------------| +| `lowcode_designer_get_endpoints` | List scripted custom endpoints | +| `lowcode_designer_get_endpoint` | Read one endpoint with route, method, JavaScript, and analyzed references | +| `lowcode_designer_get_script_autocomplete_metadata` | Discover the script globals, helpers, entities, and metadata available for a script type | +| `lowcode_designer_test_script` | Dry-run endpoint, interceptor, event handler, background job, or background worker JavaScript | +| `lowcode_designer_get_script_event_handlers` | List script event handlers | +| `lowcode_designer_get_script_event_handler` | Read one event handler | +| `lowcode_designer_get_script_background_jobs` | List script background jobs | +| `lowcode_designer_get_script_background_job` | Read one background job | +| `lowcode_designer_get_script_background_worker_scheduler_capabilities` | Read whether the runtime supports dynamic worker registration and cron scheduling | +| `lowcode_designer_get_script_background_workers` | List script background workers | +| `lowcode_designer_get_script_background_worker` | Read one background worker with schedule and JavaScript | + +## Common Sequences + +### Change an Entity or Form + +1. Read the current item with `lowcode_designer_get_entity` or `lowcode_designer_get_form`. +2. Fetch `lowcode_designer_get_mutation_metadata`. +3. Build a small mutation batch against the returned target tree. +4. Run `lowcode_designer_validate_mutations`. +5. Apply with `lowcode_designer_apply_mutations`. +6. Re-read the item and run `lowcode_designer_get_health_snapshot`. + +### Change a Dashboard or Page Group + +1. Read the page with `lowcode_designer_get_page` or list groups with `lowcode_designer_get_page_groups`. +2. Fetch mutation metadata and validate the exact batch. +3. Apply the change. +4. Re-open the page via `lowcode_designer_get_page` and confirm health. + +### Remove a Shared Item Safely + +1. Call the matching delete-impact tool first, such as `lowcode_designer_get_entity_delete_impact`, `lowcode_designer_get_enum_delete_impact`, `lowcode_designer_get_form_delete_impact`, or `lowcode_designer_get_permission_delete_impact`. +2. Fix or remove dependent references. +3. Validate the removal batch. +4. Apply and then re-check health. + +### Review or Debug a Script + +1. Read the action or endpoint with its `get_*` tool. +2. Fetch `lowcode_designer_get_script_autocomplete_metadata` for the relevant script type. +3. Run `lowcode_designer_test_script`. +4. Persist the change through mutations only after the dry run looks correct. + ## Mental Model MCP is not a raw JSON document editor. It is a semantic mutation layer over low-code concepts such as: diff --git a/docs/en/low-code/model-json.md b/docs/en/low-code/model-json.md index 4a931bdd38..4590e0827e 100644 --- a/docs/en/low-code/model-json.md +++ b/docs/en/low-code/model-json.md @@ -21,13 +21,13 @@ YourApp.Domain/ |-- YourAppLowCodeInitializer.cs |-- model/ | |-- entities/ - | | `-- Acme/Campaigns/Campaign.json + | | `-- Acme/Catalog/Product.json | |-- pages/ - | | `-- campaigns.json + | | `-- products.json | |-- forms/ - | | `-- campaign-form.json + | | `-- product-form.json | `-- permissions/ - | `-- Acme.Campaigns.json + | `-- Acme.Catalog.json `-- model-examples/ |-- product.entity.json |-- product-form.form.json @@ -40,7 +40,7 @@ Keep the whole `_Dynamic` folder and the generated initializer in source control Layout conventions: -* `entities/` and `enums/` usually follow namespace-like folders such as `entities/Acme/Campaigns/Campaign.json`. +* `entities/` and `enums/` usually follow namespace-like folders such as `entities/Acme/Catalog/Product.json`. * `pageGroups/` usually stays flat as `pageGroups/{groupName}.json`. * `pages/` stays flat as `pages/{pageName}.json`, even when the page belongs to a page group or is a dashboard. * Dashboard pages live in `pages/`; there is no separate dashboard descriptor folder. @@ -110,8 +110,8 @@ Use the descriptor schema directly when a descriptor is stored as its own JSON f ```json { "$schema": "https://raw.githubusercontent.com/abpframework/abp/rel-10.5/schemas/low-code/definitions/entity-descriptor.schema.json", - "name": "Acme.Campaigns.Campaign", - "displayName": "Campaigns", + "name": "Acme.Catalog.Product", + "displayName": "Products", "properties": [] } ``` @@ -160,7 +160,7 @@ Define enums before properties that reference them: { "enums": [ { - "name": "Acme.Campaigns.CampaignStatus", + "name": "Acme.Catalog.ProductStatus", "values": [ { "name": "Draft", "value": 0 }, { "name": "Active", "value": 1 }, @@ -178,7 +178,7 @@ Use the enum from a property with `type: "enum"` and `enumType`: { "name": "Status", "type": "enum", - "enumType": "Acme.Campaigns.CampaignStatus", + "enumType": "Acme.Catalog.ProductStatus", "defaultValue": "0" } ``` @@ -189,8 +189,8 @@ Entities describe the persisted data model. UI is not configured with legacy pro ```json { - "name": "Acme.Campaigns.Campaign", - "displayName": "Campaigns", + "name": "Acme.Catalog.Product", + "displayName": "Products", "displayProperty": "Name", "properties": [], "crossFieldValidations": [], @@ -200,7 +200,7 @@ Entities describe the persisted data model. UI is not configured with legacy pro | Field | Description | |-------|-------------| -| `name` | Required stable full entity name, for example `Acme.Campaigns.Campaign` | +| `name` | Required stable full entity name, for example `Acme.Catalog.Product` | | `displayName` | Default plural/screen label | | `displayProperty` | Property shown in lookups and foreign key display values | | `parent` | Parent entity name for child/detail entities | @@ -213,7 +213,7 @@ Entities describe the persisted data model. UI is not configured with legacy pro ```json { - "name": "Budget", + "name": "Price", "type": "money", "isRequired": true, "isUnique": false, @@ -274,7 +274,7 @@ Use entity `attachments` when each record can have multiple arbitrary files: ```json { - "name": "Acme.Campaigns.Campaign", + "name": "Acme.Catalog.Product", "attachments": { "isEnabled": true, "maxFileCount": 10, @@ -322,25 +322,25 @@ Pages create runtime routes and menu entries. They also choose how entity data i ```json { - "name": "campaigns", - "title": "Campaigns", - "icon": "fa-solid fa-bullhorn", + "name": "products", + "title": "Products", + "icon": "fa-solid fa-box", "type": "dataGrid", - "entityName": "Acme.Campaigns.Campaign", - "group": "marketing", + "entityName": "Acme.Catalog.Product", + "group": "catalog", "defaultFileExportMode": 0, "allowFileBundleExport": true, "columns": [ { "propertyName": "Name", "order": 0, "exportOrder": 0 }, { "propertyName": "Status", "order": 1, "exportOrder": 1 }, - { "propertyName": "Budget", "order": 2, "exportOrder": 2, "exportable": false } + { "propertyName": "Price", "order": 2, "exportOrder": 2, "exportable": false } ], "filters": [ { "propertyName": "Name", "control": "text", "defaultOperator": "contains" }, { "propertyName": "Status", "control": "select", "defaultOperator": "equal" } ], - "createFormName": "campaign-form", - "editFormName": "campaign-form" + "createFormName": "product-form", + "editFormName": "product-form" } ``` @@ -391,13 +391,13 @@ Use flat field placements inside each group. The current runtime and designer re ```json { - "name": "campaign-form", - "entityName": "Acme.Campaigns.Campaign", + "name": "product-form", + "entityName": "Acme.Catalog.Product", "enableSaveAndNew": true, "fields": [ { "id": "name", "label": "Name", "type": "text", "binding": "Name" }, - { "id": "status", "label": "Status", "type": "select", "binding": "Status", "enumType": "Acme.Campaigns.CampaignStatus" }, - { "id": "ownerId", "label": "Owner", "type": "lookup", "binding": "OwnerId" } + { "id": "status", "label": "Status", "type": "select", "binding": "Status", "enumType": "Acme.Catalog.ProductStatus" }, + { "id": "price", "label": "Price", "type": "money", "binding": "Price" } ], "layout": { "tabs": [ @@ -413,7 +413,7 @@ Use flat field placements inside each group. The current runtime and designer re "fields": [ { "fieldId": "name", "row": 0, "colSpan": 4 }, { "fieldId": "status", "row": 1, "colSpan": 2 }, - { "fieldId": "ownerId", "row": 1, "colSpan": 2 } + { "fieldId": "price", "row": 1, "colSpan": 2 } ] } ] @@ -448,9 +448,9 @@ Pages can use generated defaults or explicit permission configuration: { "permissionConfig": { "view": "authenticated", - "create": "Acme.Campaigns.Create", - "update": "Acme.Campaigns.Update", - "delete": "Acme.Campaigns.Delete" + "create": "Acme.Catalog.Create", + "update": "Acme.Catalog.Update", + "delete": "Acme.Catalog.Delete" } } ``` @@ -481,12 +481,12 @@ See [Interceptors](interceptors.md) and [Scripting API](scripting-api.md). { "endpoints": [ { - "name": "GetCampaignStats", - "route": "/api/custom/campaigns/stats", + "name": "GetProductStats", + "route": "/api/custom/products/stats", "method": "GET", "requireAuthentication": true, - "requiredPermissions": ["Acme.Campaigns"], - "javascript": "var count = await db.count('Acme.Campaigns.Campaign'); return ok({ total: count });" + "requiredPermissions": ["Acme.Catalog"], + "javascript": "var count = await db.count('Acme.Catalog.Product'); return ok({ total: count });" } ] } @@ -500,22 +500,22 @@ See [Custom Endpoints](custom-endpoints.md). { "eventHandlers": [ { - "name": "NotifyCampaignCompleted", - "eventName": "Acme.Campaigns.CampaignCompleted", - "javascript": "log('Campaign completed: ' + eventData.id);" + "name": "NotifyProductPublished", + "eventName": "Acme.Catalog.ProductPublished", + "javascript": "context.log('Product published: ' + eventData.id);" } ], "backgroundJobs": [ { - "name": "SendCampaignSummary", - "javascript": "log('Sending summary for ' + jobData.campaignId);" + "name": "SendProductSummary", + "javascript": "context.log('Sending summary for ' + jobData.productId);" } ], "backgroundWorkers": [ { - "name": "CampaignCleanup", + "name": "ProductCleanup", "period": 3600000, - "javascript": "log('Cleaning campaign data.');" + "javascript": "context.log('Cleaning product data.');" } ] } @@ -531,37 +531,37 @@ The complete example below shows the logical aggregate shape. Split projects sto { "enums": [ { - "name": "Acme.Campaigns.CampaignStatus", + "name": "Acme.Catalog.ProductStatus", "values": [ { "name": "Draft", "value": 0 }, { "name": "Active", "value": 1 }, - { "name": "Completed", "value": 2 } + { "name": "Archived", "value": 2 } ] } ], "entities": [ { - "name": "Acme.Campaigns.Campaign", - "displayName": "Campaigns", + "name": "Acme.Catalog.Product", + "displayName": "Products", "displayProperty": "Name", "properties": [ { "name": "Name", "type": "string", "isRequired": true, "validators": [{ "type": "maxLength", "length": 128 }] }, - { "name": "Status", "type": "enum", "enumType": "Acme.Campaigns.CampaignStatus", "defaultValue": "0" }, - { "name": "Budget", "type": "money" }, - { "name": "StartDate", "type": "date" }, + { "name": "Status", "type": "enum", "enumType": "Acme.Catalog.ProductStatus", "defaultValue": "0" }, + { "name": "Price", "type": "money" }, + { "name": "ReleaseDate", "type": "date" }, { "name": "CoverImage", "type": "image", "fileAllowedContentTypes": ["image/*"] } ] } ], "forms": [ { - "name": "campaign-form", - "entityName": "Acme.Campaigns.Campaign", + "name": "product-form", + "entityName": "Acme.Catalog.Product", "fields": [ { "id": "name", "label": "Name", "type": "text", "binding": "Name" }, - { "id": "status", "label": "Status", "type": "select", "binding": "Status", "enumType": "Acme.Campaigns.CampaignStatus" }, - { "id": "budget", "label": "Budget", "type": "money", "binding": "Budget" }, - { "id": "startDate", "label": "Start Date", "type": "date", "binding": "StartDate" }, + { "id": "status", "label": "Status", "type": "select", "binding": "Status", "enumType": "Acme.Catalog.ProductStatus" }, + { "id": "price", "label": "Price", "type": "money", "binding": "Price" }, + { "id": "releaseDate", "label": "Release Date", "type": "date", "binding": "ReleaseDate" }, { "id": "coverImage", "label": "Cover Image", "type": "image", "binding": "CoverImage" } ], "layout": { @@ -578,8 +578,8 @@ The complete example below shows the logical aggregate shape. Split projects sto "fields": [ { "fieldId": "name", "row": 0, "colSpan": 4 }, { "fieldId": "status", "row": 1, "colSpan": 2 }, - { "fieldId": "budget", "row": 1, "colSpan": 2 }, - { "fieldId": "startDate", "row": 2, "colSpan": 2 }, + { "fieldId": "price", "row": 1, "colSpan": 2 }, + { "fieldId": "releaseDate", "row": 2, "colSpan": 2 }, { "fieldId": "coverImage", "row": 2, "colSpan": 2 } ] } @@ -590,27 +590,27 @@ The complete example below shows the logical aggregate shape. Split projects sto } ], "pageGroups": [ - { "name": "marketing", "title": "Marketing", "icon": "fa-solid fa-bullhorn", "order": 10 } + { "name": "catalog", "title": "Catalog", "icon": "fa-solid fa-boxes-stacked", "order": 10 } ], "pages": [ { - "name": "campaigns", - "title": "Campaigns", + "name": "products", + "title": "Products", "type": "dataGrid", - "entityName": "Acme.Campaigns.Campaign", - "group": "marketing", + "entityName": "Acme.Catalog.Product", + "group": "catalog", "columns": [ { "propertyName": "Name", "order": 0, "exportOrder": 0 }, { "propertyName": "Status", "order": 1, "exportOrder": 1 }, - { "propertyName": "Budget", "order": 2, "exportOrder": 2, "exportable": false } + { "propertyName": "Price", "order": 2, "exportOrder": 2, "exportable": false } ], "filters": [ { "propertyName": "Name", "control": "text", "defaultOperator": "contains" }, { "propertyName": "Status", "control": "select", "defaultOperator": "equal" }, { "propertyName": "CoverImage", "control": "exists", "defaultOperator": "hasValue" } ], - "createFormName": "campaign-form", - "editFormName": "campaign-form" + "createFormName": "product-form", + "editFormName": "product-form" } ] } diff --git a/docs/en/low-code/page-groups.md b/docs/en/low-code/page-groups.md index dc53bb7d4e..90d80c7763 100644 --- a/docs/en/low-code/page-groups.md +++ b/docs/en/low-code/page-groups.md @@ -29,11 +29,11 @@ Pages reference a group by name: ```json { - "name": "orders", - "title": "Orders", + "name": "products", + "title": "Products", "type": "dataGrid", - "entityName": "Acme.Sales.Order", - "group": "sales" + "entityName": "Acme.Catalog.Product", + "group": "inventory" } ``` @@ -71,15 +71,15 @@ In split descriptor projects, a page group file in `pageGroups/` stores **one de ```json { - "name": "sales-reports", - "title": "Reports", + "name": "inventory-insights", + "title": "Insights", "icon": "fa-solid fa-folder-tree", "order": 20, - "parent": "sales" + "parent": "inventory" } ``` -For example, `pageGroups/sales-reports.json` would use that shape directly. +For example, `pageGroups/inventory-insights.json` would use that shape directly. If you are looking at the logical aggregate model instead of split files, the same data appears under the top-level `pageGroups` array: @@ -87,17 +87,17 @@ If you are looking at the logical aggregate model instead of split files, the sa { "pageGroups": [ { - "name": "sales", - "title": "Sales", - "icon": "fa-solid fa-chart-line", + "name": "inventory", + "title": "Inventory", + "icon": "fa-solid fa-boxes-stacked", "order": 10 }, { - "name": "sales-reports", - "title": "Reports", + "name": "inventory-insights", + "title": "Insights", "icon": "fa-solid fa-folder-tree", "order": 20, - "parent": "sales" + "parent": "inventory" } ] } @@ -111,7 +111,7 @@ Use CSS class strings for icons, for example: * `fa-solid fa-folder` * `fa-solid fa-folder-tree` -* `fa-solid fa-chart-line` +* `fa-solid fa-boxes-stacked` Do not treat `icon` as an image URL or file path. diff --git a/docs/en/low-code/react-runtime.md b/docs/en/low-code/react-runtime.md index e1148e8f01..98fa007ef1 100644 --- a/docs/en/low-code/react-runtime.md +++ b/docs/en/low-code/react-runtime.md @@ -148,10 +148,12 @@ Filters are rendered as an ABP-style advanced filter area. The runtime shows all File and image filters use a single `Has value` concept. The value selector controls whether the filter is applied: * `All` does not add a filter. -* `Yes` returns records with a value. -* `No` returns records without a value. +* `Yes` returns `true` for boolean filters, or records with a value for `Has value` filters. +* `No` returns `false` for boolean filters, or records without a value for `Has value` filters. -![Has value options](images/runtime-filters-has-value.png) +The screenshot below shows the shared selector pattern on the `Active` boolean filter. + +![Yes/No filter options](images/runtime-filters-has-value.png) The URL keeps the existing `lcFilters` query parameter shape. The runtime maps user-friendly filter choices to the existing backend `FilterType` values. 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 }); ```