From 90e99161f2d8a94d38906e5eb6dadc5b8b363532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=9C=E5=86=A0=E9=AD=81?= Date: Sat, 5 Sep 2026 14:00:31 +0900 Subject: [PATCH] feat(flex-fields): add Matrix and Table built-in field types Ports Matrix (polymorphic block repeater) and Table (homogeneous grid) from Dignite.Site's Dignite.FlexFields.Site into the kernel as built-ins, alongside Text/Number/DateTime/Select/Boolean/Tree, with the wire format unchanged so fields already stored under the Site implementation keep working. Adds the shared composite-type contracts (ICompositeFieldType, INormalizesValue, InlineFieldDefinition, CompositeFieldNesting) to Abstractions, SSR views to .Web, and matching ff-matrix-*/ff-table-* components to @dignite/ng.flex-fields (FieldTypeDefinition gains a composite flag). The demo seeds both types, normalizes composite values on save, and rejects over-deep nesting. Also widens the demo's field designer modal and cleans up the Matrix/Table control styling (item spacing, icon-only remove/add buttons, no redundant per-cell label in Table since the column header already names it). --- CHANGELOG.md | 48 +++ flex-fields/CLAUDE.md | 60 +++- flex-fields/README.md | 4 +- .../angular/projects/flex-fields/README.md | 11 +- .../components/flex-field-config.component.ts | 2 +- .../flex-field-control.component.ts | 2 +- .../components/flex-field-search.component.ts | 2 +- .../components/flex-field-view.component.ts | 2 +- .../field-types/built-in-field-types.spec.ts | 36 ++- .../lib/field-types/built-in-field-types.ts | 24 ++ .../lib/field-types/composite-nesting.spec.ts | 29 ++ .../src/lib/field-types/composite-nesting.ts | 41 +++ .../lib/field-types/field-type-definition.ts | 19 +- .../field-type-resolver.service.spec.ts | 6 +- .../flex-fields/src/lib/field-types/index.ts | 4 + .../inline-field-definition.spec.ts | 75 +++++ .../field-types/inline-field-definition.ts | 58 ++++ .../src/lib/field-types/matrix/index.ts | 5 + .../matrix/matrix-block-type.spec.ts | 137 +++++++++ .../field-types/matrix/matrix-block-type.ts | 64 ++++ .../matrix/matrix-config.component.html | 153 +++++++++ .../matrix/matrix-config.component.spec.ts | 247 +++++++++++++++ .../matrix/matrix-config.component.ts | 290 ++++++++++++++++++ .../matrix/matrix-configuration.ts | 9 + .../matrix/matrix-control.component.html | 54 ++++ .../matrix/matrix-control.component.spec.ts | 148 +++++++++ .../matrix/matrix-control.component.ts | 161 ++++++++++ .../matrix/matrix-view.component.html | 24 ++ .../matrix/matrix-view.component.spec.ts | 86 ++++++ .../matrix/matrix-view.component.ts | 76 +++++ .../src/lib/field-types/table/index.ts | 5 + .../table/table-config.component.html | 102 ++++++ .../table/table-config.component.spec.ts | 226 ++++++++++++++ .../table/table-config.component.ts | 229 ++++++++++++++ .../field-types/table/table-configuration.ts | 9 + .../table/table-control.component.html | 58 ++++ .../table/table-control.component.spec.ts | 133 ++++++++ .../table/table-control.component.ts | 112 +++++++ .../lib/field-types/table/table-row.spec.ts | 32 ++ .../src/lib/field-types/table/table-row.ts | 23 ++ .../table/table-view.component.html | 30 ++ .../table/table-view.component.spec.ts | 92 ++++++ .../field-types/table/table-view.component.ts | 73 +++++ .../src/lib/providers/provide-flex-fields.ts | 2 +- .../utils/flex-field-error-message.spec.ts | 62 ++++ .../src/lib/utils/flex-field-error-message.ts | 36 +++ .../flex-fields/src/lib/utils/index.ts | 1 + flex-fields/angular/src/app/app.config.ts | 2 +- .../product-fields.component.html | 2 +- .../src/app/products/products.component.html | 2 +- .../src/app/products/products.component.ts | 8 +- .../Data/ProductDemoDataSeedContributor.cs | 225 +++++++++++++- .../Localization/Demo/en.json | 1 + .../Localization/Demo/zh-Hans.json | 1 + .../Services/ProductAppService.cs | 44 ++- .../Services/ProductFieldAppService.cs | 30 +- flex-fields/docs/flexfields-design.md | 21 ++ ...Dignite.Abp.FlexFields.Abstractions.csproj | 2 +- .../Abp/FlexFields/CompositeFieldNesting.cs | 105 +++++++ .../Abp/FlexFields/ICompositeFieldType.cs | 36 +++ .../Abp/FlexFields/INormalizesValue.cs | 37 +++ .../Abp/FlexFields/InlineFieldDefinition.cs | 31 ++ .../Abp/FlexFields/InlineFieldValidator.cs | 55 ++++ .../FlexFields/Localization/Resources/en.json | 39 ++- .../FlexFields/Localization/Resources/ja.json | 39 ++- .../Localization/Resources/zh-Hans.json | 39 ++- .../Localization/Resources/zh-Hant.json | 39 ++- .../Abp/FlexFields/Matrix/MatrixBlockType.cs | 23 ++ .../Abp/FlexFields/Matrix/MatrixBlockValue.cs | 15 + .../FlexFields/Matrix/MatrixConfiguration.cs | 27 ++ .../Matrix/MatrixConfigurationNames.cs | 6 + .../Abp/FlexFields/Matrix/MatrixFieldType.cs | 162 ++++++++++ .../FlexFields/Table/TableConfiguration.cs | 26 ++ .../Table/TableConfigurationNames.cs | 6 + .../Abp/FlexFields/Table/TableFieldType.cs | 127 ++++++++ .../Dignite/Abp/FlexFields/Table/TableRow.cs | 12 + .../Dignite.Abp.FlexFields.Web.csproj | 2 +- .../FlexFields/Web/FlexFieldValueReader.cs | 45 +++ .../Abp/FlexFields/Web/FlexFieldsWebModule.cs | 2 +- .../Views/Shared/FlexFields/Matrix.cshtml | 63 ++++ .../Views/Shared/FlexFields/Table.cshtml | 64 ++++ .../FlexFields/CompositeFieldNesting_Tests.cs | 174 +++++++++++ .../Abp/FlexFields/FieldTypeResolver_Tests.cs | 20 +- .../Matrix/MatrixFieldType_Tests.cs | 222 ++++++++++++++ .../FlexFields/Table/TableFieldType_Tests.cs | 158 ++++++++++ .../Web/FlexFieldViewRendering_Tests.cs | 107 +++++++ 86 files changed, 5067 insertions(+), 54 deletions(-) create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/composite-nesting.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/composite-nesting.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/inline-field-definition.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/inline-field-definition.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/index.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-block-type.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-block-type.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.html create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-configuration.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.html create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.html create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/index.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.html create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-configuration.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.html create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-row.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-row.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.html create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/utils/flex-field-error-message.spec.ts create mode 100644 flex-fields/angular/projects/flex-fields/src/lib/utils/flex-field-error-message.ts create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/CompositeFieldNesting.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/ICompositeFieldType.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/INormalizesValue.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/InlineFieldDefinition.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/InlineFieldValidator.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Matrix/MatrixBlockType.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Matrix/MatrixBlockValue.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Matrix/MatrixConfiguration.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Matrix/MatrixConfigurationNames.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Matrix/MatrixFieldType.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Table/TableConfiguration.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Table/TableConfigurationNames.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Table/TableFieldType.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Table/TableRow.cs create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Web/Views/Shared/FlexFields/Matrix.cshtml create mode 100644 flex-fields/src/Dignite.Abp.FlexFields.Web/Views/Shared/FlexFields/Table.cshtml create mode 100644 flex-fields/test/Dignite.Abp.FlexFields.Tests/Dignite/Abp/FlexFields/CompositeFieldNesting_Tests.cs create mode 100644 flex-fields/test/Dignite.Abp.FlexFields.Tests/Dignite/Abp/FlexFields/Matrix/MatrixFieldType_Tests.cs create mode 100644 flex-fields/test/Dignite.Abp.FlexFields.Tests/Dignite/Abp/FlexFields/Table/TableFieldType_Tests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4fe16c..ad3cd673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,54 @@ so it stays clear which part of the repository actually moved. ## [Unreleased] +### Added + +#### flex-fields + +- **Two new built-in field types, `Matrix` and `Table` — the first *composite* ones, whose + configuration declares whole field definitions inline.** `Matrix` is a repeatable list of + polymorphic blocks (the admin declares named block types up front, each with its own sub-fields); + `Table` is a homogeneous grid over one shared column schema. Both were written and proven in + Dignite.Site's `Dignite.FlexFields.Site` and are ported here **with the wire format unchanged** — + registration keys `Matrix`/`Table`, configuration keys `Matrix.BlockTypes`/`Table.Columns`, and + camelCase `{blockTypeName, values}` / `{values}` value arrays — so fields already stored against + the Site implementation keep working as-is. They ship as built-ins rather than a bolt-on package + because, unlike `FileExplorer` or `CKEditor`, they depend on nothing outside the kernel's own + vocabulary; what made them worth moving is that the two contracts below have to be answerable + without knowing either concrete type. + - `Dignite.Abp.FlexFields.Abstractions` gains `MatrixFieldType`/`TableFieldType` and their + configuration types, plus four kernel contracts they share: **`ICompositeFieldType`** + (`GetInlineFields`, so a host can ask "does this type contain other fields, and which" without + naming a concrete type — an interface rather than an `IsComposite` bool, because every caller + that asks also has to walk those fields), **`INormalizesValue`** (`Normalize`, the canonical wire + shape — deliberately *not* folded into `Validate`, which returns only errors and never the parsed + value, so a value with the wrong key casing would otherwise validate cleanly and then be stored + verbatim and be unreadable to every camelCase reader downstream), **`InlineFieldDefinition`** + (one inline field; carries `Required`, which a `FlexFieldData` cannot), and + **`CompositeFieldNesting`** (`MaxDepth = 3` and the bounded measurement that enforces it — a + configuration is a tree of unbounded depth and every reader of it recurses, so it is capped once + on write instead of guarded in each reader). + - `Dignite.Abp.FlexFields.Web` gains `Views/Shared/FlexFields/Matrix.cshtml` and `Table.cshtml`, + which recurse through the existing `` dispatch for each sub-field rather than + re-implementing rendering per type. No `Search/` partials: both types have + `IndexValueType == null` — a list of composite objects has no typed index column to decompose + into — so neither can be marked `Searchable`. + - `@dignite/ng.flex-fields` gains the matching config / control / view components + (`ff-matrix-config|control|view`, `ff-table-config|control|view`), registered in + `BUILT_IN_FIELD_TYPES`, so an existing `provideFlexFields()` call already covers them. + `FieldTypeDefinition` gains an optional **`composite`** flag, which Matrix and Table set and the + config editors use to stop offering composite types once the nesting limit is reached; the + server's `CompositeFieldNesting` remains the authority, that mirror is a courtesy. + - Neither contract is invoked by the kernel — a host calls them, and the demo now shows both: + `ProductAppService` normalizes the value bag before validating and saving, and + `ProductFieldAppService` refuses a too-deeply-nested configuration on create and update. + - **Localization moved with them**: the `FieldType:Matrix`/`FieldType:Table`, `Matrix:*`, `Table:*` + and `Validate:Matrix:*`/`Validate:Table:*` texts now live in the `FlexFields` resource + (`Dignite.Abp.FlexFields.Abstractions`) instead of Site's own `FlexFieldsSite` resource, in all + four shipped cultures (`en`, `ja`, `zh-Hans`, `zh-Hant`). Three general validation keys the + Angular side's shared error-message helper needs came along with them: **`Validate:MinValue`**, + **`Validate:MaxValue`** and **`Validate:MaxLength`**. + ## [10.0.0-rc.15] - 2026-09-05 ### Fixed diff --git a/flex-fields/CLAUDE.md b/flex-fields/CLAUDE.md index 619ac457..f5fac8fa 100644 --- a/flex-fields/CLAUDE.md +++ b/flex-fields/CLAUDE.md @@ -32,7 +32,7 @@ before changing any contract; it records what was rejected and why. | Project | Responsibility | Depends on | |---|---|---| -| `FlexFields.Abstractions` | `IFieldType`/`FieldTypeBase` + the six built-ins, `IFlexFieldData`, `IHasFlexFields`, `FlexFieldValue`, query vocabulary, localization | ABP Core, Localization | +| `FlexFields.Abstractions` | `IFieldType`/`FieldTypeBase` + the eight built-ins, `IFlexFieldData`, `IHasFlexFields`, `FlexFieldValue`, query vocabulary, localization, plus the composite-type contracts (`ICompositeFieldType`, `INormalizesValue`, `InlineFieldDefinition`, `CompositeFieldNesting`) | ABP Core, Localization | | `FlexFields.Domain.Shared` | `FlexFieldConsts` only | — | | `FlexFields.Domain` | `IFlexField` (Entity contract), `IFlexFieldProvider` and the other seams, provider-neutral `FlexFieldValidator`/`FlexFieldValueMigrator` | Abstractions, Domain.Shared, ABP DDD | | `FlexFields.EntityFrameworkCore` | `FlexFieldIndexValue` (relational-only), index/repository base classes, model-creating extensions | Domain | @@ -40,7 +40,7 @@ before changing any contract; it records what was rejected and why. | `FlexFields.Web` | ``/`` TagHelpers + default `.cshtml` per built-in type — SSR counterpart to the Angular library's ``/``. No config/control TagHelpers | Abstractions | | `FlexFields.Installer` | ABP Studio/Suite install entry point, embeds the module's `.abpmdl` | `Volo.Abp.VirtualFileSystem` | -Bolt-on field types (optional, not part of the six above): `FlexFields.FileExplorer` (the field type +Bolt-on field types (optional, not part of the eight above): `FlexFields.FileExplorer` (the field type itself, references only Abstractions) and `FlexFields.FileExplorer.Web` (its `` rendering — file name/size/MIME type/link, read straight out of the value the Angular picker already denormalized at pick time; no search partial, since `FileExplorerFieldType.IndexValueType` is `null`). @@ -78,10 +78,56 @@ DbContext of its own to run one. | `Select` | `SelectFieldType` | `select/` | | `Boolean` | `BooleanFieldType` | `boolean/` | | `Tree` | `TreeFieldType` | `tree/` | +| `Matrix` | `MatrixFieldType` | `matrix/` | +| `Table` | `TableFieldType` | `table/` | Renaming any of these again "for consistency" orphans every field already stored under the current key. `built-in-field-types.spec.ts` asserts all of them for that reason. +## Composite field types (`Matrix`, `Table`) + +Two of the eight built-ins are **composite**: their *configuration* declares further whole field +definitions inline, so a field definition is a tree rather than a flat record. Ported in from +Dignite.Site's `Dignite.FlexFields.Site` with the wire format unchanged — the persisted keys are +`Matrix`/`Table` (registration) and `Matrix.BlockTypes`/`Table.Columns` (configuration), and the values +stay camelCase `{blockTypeName, values}` / `{values}` arrays. Same rule as the table above: these are +stored data, not names to tidy. + +- **`ICompositeFieldType`** — `GetInlineFields(configuration)`, flattened. An interface rather than an + `IsComposite` bool because every caller that cares also has to walk the nested fields; a bool would + leave each one switching on the concrete type to reach them. +- **`InlineFieldDefinition`** — one inline field: a Matrix block type's sub-field, or a Table column. + Not a `FlexFieldData`, because it carries `Required` — in the kernel proper that flag belongs to a + field's *usage* (`FlexFieldValue.Required`), and an inline field has no usage record to put it in. +- **`INormalizesValue`** — `Normalize(value)`, the canonical wire shape. Separate from `Validate` + because validation answers "is this acceptable" and returns only errors, never the parsed value: a + value with the wrong key casing validates fine and is then stored verbatim, unreadable by every + camelCase reader downstream. +- **`CompositeFieldNesting.MaxDepth = 3`** — a top-level field may be composite and so may its + sub-fields; what *those* declare must be scalar. `ExceedsMaxDepth` carries its own recursion budget, + because it is the first thing to walk an unvetted client configuration. + +Both are `IndexValueType == null` (a list of composite objects has no typed index column), so neither +ships a `Views/Shared/FlexFields/Search/` partial and neither can be marked `Searchable`. + +**Neither contract is called by the kernel** — a host calls them, and the demo is the worked example: +`ProductAppService` runs `INormalizesValue.Normalize` over the bag before validating and saving; +`ProductFieldAppService` refuses a configuration `CompositeFieldNesting.ExceedsMaxDepth` reports on, +on both create and update. + +On the Angular side the two live in `@dignite/ng.flex-fields` at +`angular/projects/flex-fields/src/lib/field-types/matrix/` and `table/`, registered in +`BUILT_IN_FIELD_TYPES` (so `provideFlexFields()` already covers them — no extra provide call), with +selectors `ff-matrix-config|control|view` and `ff-table-config|control|view`. `FieldTypeDefinition` +gained a `composite?: boolean` that Matrix and Table set, which the config editors use to stop offering +composite types at max depth (`MAX_COMPOSITE_NESTING_DEPTH`/`COMPOSITE_NESTING_DEPTH`/`allowsCompositeAt` +in `field-types/composite-nesting.ts`). That mirror is a courtesy; `CompositeFieldNesting` on the server +is the authority. The two also share `InlineFieldDefinition`/`normalizeInlineFieldDefinitions` +(`field-types/inline-field-definition.ts`) — the client-side counterpart of the C# type, plus the +re-casing a stored *configuration* still needs, since only field *values* go through +`INormalizesValue` — and `flexFieldErrorMessage` (`utils/flex-field-error-message.ts`), which is what +the three `Validate:MinValue`/`MaxValue`/`MaxLength` keys were added to the `FlexFields` resource for. + ## The seams The kernel's only information entry point is `IFlexFieldProvider` — a downstream merges its @@ -140,14 +186,18 @@ describes, wired to a real feature instead of the test project's throwaway `Test - **`Services/ProductFieldAppService.cs`** — field CRUD, demonstrating the ordering `IFlexFieldValueMigrator` documents: rename rewrites every product's bag *before* the definition's own `Name` changes; delete removes bag values *before* the definition; flipping `Searchable` calls - `IFlexFieldIndexManager.RebuildAsync()`. + `IFlexFieldIndexManager.RebuildAsync()`. Also the enforcement point for + `CompositeFieldNesting.ExceedsMaxDepth`, on create and update alike. - **`Services/ProductAppService.cs`** — product CRUD plus `SearchAsync`, POST rather than the GET a `Get*`-prefixed name would default to. ABP's conventional controllers derive the URL from the *method name* convention, not from an `[HttpPost("...")]` attribute's route template string — a method still named `GetListAsync` collides on the same URL as `CreateAsync` no matter what - attribute you add. The rename is why it's `SearchAsync`, at `POST /api/app/product/search`. + attribute you add. The rename is why it's `SearchAsync`, at `POST /api/app/product/search`. Also + where `INormalizesValue.Normalize` runs over the bag, before validating and saving. - **`Data/ProductDemoDataSeedContributor.cs`** — seeds one `ProductField` per built-in field type - plus the FileExplorer bolt-on, and five products, so a first `dotnet run -- --migrate-database` + (including `Table` and `Matrix`, whose values two of the products carry for real) plus the + FileExplorer bolt-on and two CKEditor ones — eleven fields — and five products, so a first + `dotnet run -- --migrate-database` leaves the demo immediately browsable instead of empty. One product's `images` field gets a real uploaded file (`FileDescriptorManager.CreateAsync` directly, bypassing the `[Authorize]`-gated app service the same way the field/product repositories are used directly elsewhere in this class) into diff --git a/flex-fields/README.md b/flex-fields/README.md index 78670f9b..6b5cc631 100644 --- a/flex-fields/README.md +++ b/flex-fields/README.md @@ -30,11 +30,11 @@ defines "the concrete one." | Package | Purpose | |---|---| | `Dignite.Abp.FlexFields.Domain.Shared` | Shared constants (`FlexFieldConsts`). Dependency-free. | -| `Dignite.Abp.FlexFields.Abstractions` | DDD-free contracts and vocabulary: `IFlexFieldData`/`FlexFieldData`, `IHasFlexFields`/`FlexFieldDictionary`, `FlexFieldValue`, `IFieldType` + the built-in field types (Text/Number/DateTime/Select/Boolean/Tree), the query vocabulary, and the field-lifecycle Etos (`FlexFieldRenamedEto`, `FlexFieldDeletedEto`). Referencing this package alone is enough to implement a custom field type or type a downstream's DTOs. | +| `Dignite.Abp.FlexFields.Abstractions` | DDD-free contracts and vocabulary: `IFlexFieldData`/`FlexFieldData`, `IHasFlexFields`/`FlexFieldDictionary`, `FlexFieldValue`, `IFieldType` + the built-in field types (Text/Number/DateTime/Select/Boolean/Tree, plus the composite Matrix/Table and their `ICompositeFieldType`/`INormalizesValue`/`InlineFieldDefinition`/`CompositeFieldNesting` contracts), the query vocabulary, and the field-lifecycle Etos (`FlexFieldRenamedEto`, `FlexFieldDeletedEto`). Referencing this package alone is enough to implement a custom field type or type a downstream's DTOs. | | `Dignite.Abp.FlexFields.Domain` | The Entity contract (`IFlexField : IAggregateRoot`) and the DDD-aware seams: `IFlexFieldProvider`, `IFlexFieldValidator` (+ default impl), `IFlexFieldIndexManager`, `IFlexFieldQueryExecutor`, `IFlexFieldValueMigrator` (+ its one provider-agnostic default impl), `IFlexFieldRepository`. | | `Dignite.Abp.FlexFields.EntityFrameworkCore` | EF Core support (not ownership): `ConfigureFlexFieldsProperty`/`ConfigureFlexField`/`ConfigureFlexFieldIndex` model-builder extensions, the typed pivot-row shape (`FlexFieldIndexValue`), and abstract base classes for the index manager, query executor, and field repository. Ships no `DbContext` and no table of its own. | | `Dignite.Abp.FlexFields.MongoDB` | MongoDB support: queries and indexes the `FlexFieldDictionary` in place, so writes need almost no index synchronization. Deliberately has **no** counterpart to `FlexFieldIndexValue` — that shape is a relational pivot row. | -| `@dignite/ng.flex-fields` (npm) | Angular UI: config / control / view / search components for all six field types, the `FieldTypeResolver` registry, and `provideFlexFields()`. See [`angular/projects/flex-fields`](./angular/projects/flex-fields/README.md). | +| `@dignite/ng.flex-fields` (npm) | Angular UI: config / control / view / search components for all eight field types, the `FieldTypeResolver` registry, and `provideFlexFields()`. See [`angular/projects/flex-fields`](./angular/projects/flex-fields/README.md). | ## Install diff --git a/flex-fields/angular/projects/flex-fields/README.md b/flex-fields/angular/projects/flex-fields/README.md index 133d0999..dc1aa37f 100644 --- a/flex-fields/angular/projects/flex-fields/README.md +++ b/flex-fields/angular/projects/flex-fields/README.md @@ -36,7 +36,7 @@ inside `<21.1.0` if you need a single copy; otherwise expect `abp-tree` to run o ## Field types -Six built-in types, each with up to four role components — **config** (design the field), +Eight built-in types, each with up to four role components — **config** (design the field), **control** (edit a value), **view** (display a value) and **search** (filter by it): | Registration key | Type | Roles | @@ -47,6 +47,15 @@ Six built-in types, each with up to four role components — **config** (design | `Select` | single or multiple choice | config, control, view, search | | `Boolean` | boolean | config, control, view, search | | `Tree` | single or multiple selection from a node tree | config, control, view, search | +| `Matrix` | repeatable list of polymorphic blocks, each block type with its own sub-fields | config, control, view | +| `Table` | repeatable grid, one shared column schema for every row | config, control, view | + +`Matrix` and `Table` are **composite**: their configuration declares further fields, and their config, +control and view components recurse through `` / `` / +`` to render them. They ship no search component — the server's +`IndexValueType` is `null` for both, so there is nothing to filter on. How deep the recursion may go +is the server's `CompositeFieldNesting.MaxDepth`; the config editors mirror it only so they can stop +offering composite types once the limit is reached. The registration keys are the values persisted in `IFlexFieldData.FieldTypeName` on the server. They are **data, not class names** — `Text` is served by `TextFieldType` in C# and diff --git a/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-config.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-config.component.ts index d17f8d56..15af4633 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-config.component.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-config.component.ts @@ -9,7 +9,7 @@ import { } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { Subscription } from 'rxjs'; -import { FieldTypeResolver } from '../field-types'; +import { FieldTypeResolver } from '../field-types/field-type-resolver.service'; import { FlexFieldData } from '../models'; /** diff --git a/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-control.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-control.component.ts index 7cc26de6..9e687a7e 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-control.component.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-control.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnChanges, Type, ViewChild, ViewContainerRef, inject } from '@angular/core'; import { FormGroup } from '@angular/forms'; -import { FieldTypeResolver } from '../field-types'; +import { FieldTypeResolver } from '../field-types/field-type-resolver.service'; import { FlexFieldValue } from '../models'; /** diff --git a/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-search.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-search.component.ts index f1e138a8..c73a8cbb 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-search.component.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-search.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnChanges, Type, ViewChild, ViewContainerRef, inject } from '@angular/core'; import { FormGroup } from '@angular/forms'; -import { FieldTypeResolver } from '../field-types'; +import { FieldTypeResolver } from '../field-types/field-type-resolver.service'; import { FlexFieldValue } from '../models'; /** diff --git a/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-view.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-view.component.ts index d685be87..d2bbfdec 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-view.component.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/components/flex-field-view.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges, Type, ViewChild, ViewContainerRef, inject } from '@angular/core'; -import { FieldTypeResolver } from '../field-types'; +import { FieldTypeResolver } from '../field-types/field-type-resolver.service'; import { FlexFieldValue } from '../models'; /** Renders the read-only **display** of one flex field's value, whichever type it is. */ diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/built-in-field-types.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/built-in-field-types.spec.ts index c8ad3273..d7709a33 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/built-in-field-types.spec.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/built-in-field-types.spec.ts @@ -2,22 +2,24 @@ import { FormBuilder } from '@angular/forms'; import { BUILT_IN_FIELD_TYPES } from './built-in-field-types'; import { BooleanConfiguration } from './boolean'; import { DateTimeConfiguration } from './date'; +import { MatrixConfiguration } from './matrix'; import { NumberConfiguration } from './number'; import { SelectConfiguration } from './select'; +import { TableConfiguration } from './table'; import { TextConfiguration } from './text'; import { TreeConfiguration } from './tree'; /** * These are wire-contract tests, not coverage. * - * Every string asserted below is a **stored value** shared with the server: the six registration keys + * Every string asserted below is a **stored value** shared with the server: the eight registration keys * live in `IFlexFieldData.FieldTypeName`, and the configuration keys are the literal keys of * `FieldConfigurationDictionary`. Renaming any of them here — however tidy the new name looks — * silently orphans every field already saved under the old one, and nothing else in the build would * catch it. If one of these fails, the fix is almost never to update the expectation. */ describe('built-in field types', () => { - it('registers exactly the six the server ships, under the server keys', () => { + it('registers exactly the eight the server ships, under the server keys', () => { expect(BUILT_IN_FIELD_TYPES.map(fieldType => fieldType.name)).toEqual([ 'Text', 'Number', @@ -25,6 +27,8 @@ describe('built-in field types', () => { 'Select', 'Boolean', 'Tree', + 'Matrix', + 'Table', ]); }); @@ -42,14 +46,26 @@ describe('built-in field types', () => { } }); - it('has a search component for every type except DateTime', () => { + it('has a search component for every type except DateTime, Matrix and Table', () => { const withoutSearch = BUILT_IN_FIELD_TYPES.filter( fieldType => !fieldType.searchComponent, ).map(fieldType => fieldType.name); - // Tracked gap, not an oversight: the server indexes DateTime and allows six operators on it, so a - // date range filter is a real thing to build — it just was never part of what was migrated. - expect(withoutSearch).toEqual(['DateTime']); + // DateTime is a tracked gap, not an oversight: the server indexes it and allows six operators on + // it, so a date range filter is a real thing to build — it just was never part of what was + // migrated. Matrix and Table are the opposite — permanent: `IndexValueType` is null for both on + // the server, so their values never reach the query index and there is nothing to filter on. + expect(withoutSearch).toEqual(['DateTime', 'Matrix', 'Table']); + }); + + it('marks exactly Matrix and Table as composite', () => { + // The flag exists so a composite config editor can stop offering composite types once + // CompositeFieldNesting.MaxDepth is reached. Every scalar type must leave it unset. + const composite = BUILT_IN_FIELD_TYPES.filter(fieldType => fieldType.composite === true).map( + fieldType => fieldType.name, + ); + + expect(composite).toEqual(['Matrix', 'Table']); }); it('cannot be mutated at runtime', () => { @@ -104,6 +120,14 @@ describe('configuration keys', () => { expect(keysOf(new TreeConfiguration())).toEqual(['Tree.Multiple', 'Tree.Nodes']); }); + it('Matrix', () => { + expect(keysOf(new MatrixConfiguration())).toEqual(['Matrix.BlockTypes']); + }); + + it('Table', () => { + expect(keysOf(new TableConfiguration())).toEqual(['Table.Columns']); + }); + it('seeds Text.CharLimit with the server default of 256', () => { const value = new FormBuilder().group(new TextConfiguration()).value; expect(value['Text.CharLimit']).toBe(256); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/built-in-field-types.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/built-in-field-types.ts index d628f67d..3160f7fe 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/built-in-field-types.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/built-in-field-types.ts @@ -34,6 +34,8 @@ import { TreeSearchComponent, TreeViewComponent, } from './tree'; +import { MatrixConfigComponent, MatrixControlComponent, MatrixViewComponent } from './matrix'; +import { TableConfigComponent, TableControlComponent, TableViewComponent } from './table'; /** * The field types this library ships. Each `name` is the value persisted in @@ -47,6 +49,8 @@ import { * | `Select` | `SelectFieldType` | `select/` | * | `Boolean` | `BooleanFieldType` | `boolean/` | * | `Tree` | `TreeFieldType` | `tree/` | + * | `Matrix` | `MatrixFieldType` | `matrix/` | + * | `Table` | `TableFieldType` | `table/` | * * A frozen array, not something to mutate at runtime: contributors register their own types through * the `FLEX_FIELD_TYPES` multi-provider instead. @@ -101,4 +105,24 @@ export const BUILT_IN_FIELD_TYPES: readonly FieldTypeDefinition[] = Object.freez viewComponent: TreeViewComponent, searchComponent: TreeSearchComponent, }, + { + // No search component: `MatrixFieldType.IndexValueType` is null on the server — the value is a + // list of composite block objects, not something a filter control could meaningfully query. + name: 'Matrix', + displayNameKey: 'FlexFields::FieldType:Matrix', + configComponent: MatrixConfigComponent, + controlComponent: MatrixControlComponent, + viewComponent: MatrixViewComponent, + composite: true, + }, + { + // No search component: `TableFieldType.IndexValueType` is null on the server — the value is a + // list of composite row objects, not something a filter control could meaningfully query. + name: 'Table', + displayNameKey: 'FlexFields::FieldType:Table', + configComponent: TableConfigComponent, + controlComponent: TableControlComponent, + viewComponent: TableViewComponent, + composite: true, + }, ]); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/composite-nesting.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/composite-nesting.spec.ts new file mode 100644 index 00000000..6ed4f9c7 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/composite-nesting.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { + MAX_COMPOSITE_NESTING_DEPTH, + allowsCompositeAt, + nextCompositeNestingDepth, +} from './composite-nesting'; + +describe('allowsCompositeAt', () => { + it('leaves room for a composite at the first two levels but not the third', () => { + // MaxDepth 3: a top-level field may be composite (1), so may its sub-fields (2), but what those + // declare (3) has to be scalar. Mirrors CompositeFieldNesting.MaxDepth on the server. + expect(MAX_COMPOSITE_NESTING_DEPTH).toBe(3); + expect(allowsCompositeAt(1)).toBe(true); + expect(allowsCompositeAt(2)).toBe(true); + expect(allowsCompositeAt(3)).toBe(false); + }); +}); + +describe('nextCompositeNestingDepth', () => { + it("puts a top-level field type's own sub-fields at depth 2", () => { + const depth = TestBed.runInInjectionContext(() => nextCompositeNestingDepth()); + expect(depth).toBe(2); + }); + + // The "one deeper than whatever it was mounted inside" half needs a real node-injector chain to + // exercise `skipSelf`, so it is asserted where it actually matters: the config components' + // `fieldTypeOptions`, which stops offering composite types once the depth runs out. See + // `matrix-config.component.spec.ts` / `table-config.component.spec.ts`. +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/composite-nesting.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/composite-nesting.ts new file mode 100644 index 00000000..6f6b17da --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/composite-nesting.ts @@ -0,0 +1,41 @@ +import { InjectionToken, inject } from '@angular/core'; + +/** + * How many levels of field definition a field may span, counting the field itself as 1. At the current + * value of `3`: a top-level field may be composite, and so may its columns/sub-fields (one layer of + * nesting) - but what *that* nested composite declares must be scalar. + * + * Mirrors `CompositeFieldNesting.MaxDepth` + * (`src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/CompositeFieldNesting.cs`), which is + * the authority - a host that enforces it (the demo's `ProductFieldAppService` does) refuses a + * configuration that exceeds it whatever this file says. This copy exists so the designer can stop + * *offering* the choice instead of letting the admin build something the save will reject. + */ +export const MAX_COMPOSITE_NESTING_DEPTH = 3; + +/** + * The depth at which the *sub-fields* of the config editor currently being rendered live. Absent at the + * top level, where a field is depth 1 and its own sub-fields would be depth 2. + */ +export const COMPOSITE_NESTING_DEPTH = new InjectionToken('COMPOSITE_NESTING_DEPTH'); + +/** + * Factory for a composite config component's own `COMPOSITE_NESTING_DEPTH` provider: one deeper than + * whatever it was mounted inside. + * + * `ff-flex-field-config` mounts a nested config editor with `ViewContainerRef.createComponent`, which + * uses the host element's injector - so the chain of composite components on screen *is* the injector + * chain, and `skipSelf` walks it. Nothing has to thread a depth through the library's inputs (which is + * just as well: it has none for it). + */ +export function nextCompositeNestingDepth(): number { + return (inject(COMPOSITE_NESTING_DEPTH, { optional: true, skipSelf: true }) ?? 1) + 1; +} + +/** + * Whether a field at `depth` may itself be composite - i.e. whether there is room left underneath it + * for the fields its own configuration would declare. + */ +export function allowsCompositeAt(depth: number): boolean { + return depth < MAX_COMPOSITE_NESTING_DEPTH; +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/field-type-definition.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/field-type-definition.ts index 5caf9f2d..94727285 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/field-type-definition.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/field-type-definition.ts @@ -16,7 +16,7 @@ import { Type } from '@angular/core'; export interface FieldTypeDefinition { /** * Registration key. Must equal the server field type's `Name` — `Text`, `Number`, - * `DateTime`, `Select`, `Boolean`, `Tree` for the built-ins. + * `DateTime`, `Select`, `Boolean`, `Tree`, `Matrix`, `Table` for the built-ins. * * These are stored values, not class names. `Text` is served by `TextFieldType` on the server * and `TextControlComponent` here; renaming the key would orphan every field already bound to it. @@ -40,4 +40,21 @@ export interface FieldTypeDefinition { /** Filters by the field. Absent when the type has no meaningful search UI. */ searchComponent?: Type; + + /** + * Whether this type's own configuration declares further fields — the client-side counterpart of + * the server's `ICompositeFieldType`. `Matrix` and `Table` are the built-ins that set it. + * + * Declared by the same package that ships the type, so it is not a host-maintained mirror the way a + * copy of `IndexValueType` would be — the package that owns `MatrixConfigComponent` is the same one + * that knows a Matrix has sub-fields. It is used for exactly one thing: letting a composite config + * editor stop *offering* composite types once {@link MAX_COMPOSITE_NESTING_DEPTH} is reached, rather + * than letting an admin build a definition the save will reject. The server's + * `CompositeFieldNesting` remains the authority and refuses an over-deep definition regardless of + * what this says. + * + * This does not contradict the "rendering only" rule above: whether a config editor recurses is a + * client rendering fact, not a server decision restated here. + */ + composite?: boolean; } diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/field-type-resolver.service.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/field-type-resolver.service.spec.ts index 157265c3..9e286ce2 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/field-type-resolver.service.spec.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/field-type-resolver.service.spec.ts @@ -15,7 +15,7 @@ describe('FieldTypeResolver', () => { expect(resolver.get('Text').displayNameKey).toBe('FlexFields::FieldType:Text'); expect(resolver.get('Tree').displayNameKey).toBe('FlexFields::FieldType:Tree'); - expect(resolver.getAll()).toHaveLength(6); + expect(resolver.getAll()).toHaveLength(8); }); it('throws on an unregistered key rather than rendering nothing', () => { @@ -39,7 +39,7 @@ describe('FieldTypeResolver', () => { const resolver = TestBed.inject(FieldTypeResolver); expect(resolver.get('CkEditor')).toEqual(boltOn); - expect(resolver.getAll()).toHaveLength(7); + expect(resolver.getAll()).toHaveLength(9); }); it('lets a separate package register on its own', () => { @@ -63,6 +63,6 @@ describe('FieldTypeResolver', () => { expect(resolver.get('Text')).toEqual(replacement); // Replaced, not duplicated. - expect(resolver.getAll()).toHaveLength(6); + expect(resolver.getAll()).toHaveLength(8); }); }); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/index.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/index.ts index 86138046..dd9a0a08 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/field-types/index.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/index.ts @@ -1,13 +1,17 @@ export * from './built-in-field-types'; +export * from './composite-nesting'; export * from './field-type-config-base'; export * from './field-type-control-base'; export * from './field-type-definition'; export * from './field-type-resolver.service'; export * from './field-type.tokens'; +export * from './inline-field-definition'; export * from './boolean'; export * from './date'; +export * from './matrix'; export * from './number'; export * from './select'; +export * from './table'; export * from './text'; export * from './tree'; diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/inline-field-definition.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/inline-field-definition.spec.ts new file mode 100644 index 00000000..d6d04dec --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/inline-field-definition.spec.ts @@ -0,0 +1,75 @@ +import { normalizeInlineFieldDefinitions } from './inline-field-definition'; + +describe('normalizeInlineFieldDefinitions', () => { + it('reads anything that is not an array as empty', () => { + expect(normalizeInlineFieldDefinitions(undefined)).toEqual([]); + expect(normalizeInlineFieldDefinitions(null)).toEqual([]); + expect(normalizeInlineFieldDefinitions('')).toEqual([]); + expect(normalizeInlineFieldDefinitions({ name: 'title' })).toEqual([]); + }); + + it('fills every default for a member with nothing in it', () => { + expect(normalizeInlineFieldDefinitions([{}, null])).toEqual([ + { name: '', displayName: '', description: undefined, fieldTypeName: '', required: false, configuration: {} }, + { name: '', displayName: '', description: undefined, fieldTypeName: '', required: false, configuration: {} }, + ]); + }); + + it('passes a fully-populated camelCase member straight through', () => { + // camelCase is what the designer writes and what these readers always emit, so a member that + // arrives camelCase is already correct. + const stored = [ + { + name: 'title', + displayName: 'Title', + description: 'The headline', + fieldTypeName: 'Text', + required: true, + configuration: { 'Text.CharLimit': 120 }, + }, + ]; + + expect(normalizeInlineFieldDefinitions(stored)).toEqual(stored); + }); + + it('reads a PascalCase member into the same camelCase result', () => { + // What a definition authored server-side from the typed C# configuration classes looks like once + // EF Core has serialized it with System.Text.Json's default options. + const stored = [ + { + Name: 'title', + DisplayName: 'Title', + Description: 'The headline', + FieldTypeName: 'Text', + Required: true, + Configuration: { 'Text.CharLimit': 120 }, + }, + ]; + + expect(normalizeInlineFieldDefinitions(stored)).toEqual([ + { + name: 'title', + displayName: 'Title', + description: 'The headline', + fieldTypeName: 'Text', + required: true, + configuration: { 'Text.CharLimit': 120 }, + }, + ]); + }); + + it('reads casings mixed across members, and mixed within one member', () => { + const normalized = normalizeInlineFieldDefinitions([ + { Name: 'title', DisplayName: 'Title', FieldTypeName: 'Text', Required: true }, + { name: 'qty', displayName: 'Quantity', fieldTypeName: 'Number', required: false }, + // One member with each key in whichever casing - camelCase wins per key, never per member. + { name: 'note', DisplayName: 'Note', fieldTypeName: 'Text', Required: true, Configuration: { a: 1 } }, + ]); + + expect(normalized.map(field => field.name)).toEqual(['title', 'qty', 'note']); + expect(normalized.map(field => field.displayName)).toEqual(['Title', 'Quantity', 'Note']); + expect(normalized.map(field => field.fieldTypeName)).toEqual(['Text', 'Number', 'Text']); + expect(normalized.map(field => field.required)).toEqual([true, false, true]); + expect(normalized.map(field => field.configuration)).toEqual([{}, {}, { a: 1 }]); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/inline-field-definition.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/inline-field-definition.ts new file mode 100644 index 00000000..d6c9cf47 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/inline-field-definition.ts @@ -0,0 +1,58 @@ +/** + * One field defined inline as part of a composite field type's own configuration - a `Matrix` block + * type's sub-field, or a `Table` column. Mirrors `InlineFieldDefinition` + * (`src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/InlineFieldDefinition.cs`), shared + * between the two rather than each declaring its own copy. + * + * camelCase is canonical - it is what the Angular designer writes into a stored configuration, and what + * `INormalizesValue` produces for values - so it is the only casing these readers ever *write*. They + * *read* either one, though: a field definition can also be authored server-side from the typed C# + * configuration classes (`new TableConfiguration { Columns = ... }` - the demo's + * `ProductDemoDataSeedContributor` is the worked example), and EF Core's JSON value converter serializes + * those with System.Text.Json's *default* options, so what lands in the database and comes back over the + * API is PascalCase: `{"Name":"title","FieldTypeName":"Text","Configuration":{...}}`. The server's own + * readers already accept either casing (`JsonSerializerDefaults.Web`), so these are the client-side half + * of the same lenience - same dual-casing round trip as `Select.Options`/`SelectListItem`, in the + * opposite direction. + */ +export interface InlineFieldDefinition { + name: string; + displayName: string; + description?: string; + fieldTypeName: string; + required: boolean; + configuration: Record; +} + +/** A stored inline field definition in either casing - see {@link InlineFieldDefinition} for why both. */ +type RawInlineFieldDefinition = Partial<{ + name: string; + Name: string; + displayName: string; + DisplayName: string; + description: string; + Description: string; + fieldTypeName: string; + FieldTypeName: string; + required: boolean; + Required: boolean; + configuration: Record; + Configuration: Record; +}>; + +function normalizeInlineFieldDefinition(item: unknown): InlineFieldDefinition { + const source = (item ?? {}) as RawInlineFieldDefinition; + return { + name: source.name ?? source.Name ?? '', + displayName: source.displayName ?? source.DisplayName ?? '', + description: source.description ?? source.Description, + fieldTypeName: source.fieldTypeName ?? source.FieldTypeName ?? '', + required: source.required ?? source.Required ?? false, + configuration: source.configuration ?? source.Configuration ?? {}, + }; +} + +/** Reads a stored `Matrix.BlockTypes[].fields` or `Table.Columns` configuration value, defensively. */ +export function normalizeInlineFieldDefinitions(source: unknown): InlineFieldDefinition[] { + return Array.isArray(source) ? source.map(normalizeInlineFieldDefinition) : []; +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/index.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/index.ts new file mode 100644 index 00000000..d78e7ce9 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/index.ts @@ -0,0 +1,5 @@ +export * from './matrix-block-type'; +export * from './matrix-config.component'; +export * from './matrix-configuration'; +export * from './matrix-control.component'; +export * from './matrix-view.component'; diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-block-type.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-block-type.spec.ts new file mode 100644 index 00000000..d1f9ad11 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-block-type.spec.ts @@ -0,0 +1,137 @@ +import { normalizeMatrixBlockTypes, normalizeMatrixBlockValues } from './matrix-block-type'; + +describe('normalizeMatrixBlockTypes', () => { + it('reads anything that is not an array as empty', () => { + expect(normalizeMatrixBlockTypes(undefined)).toEqual([]); + expect(normalizeMatrixBlockTypes(null)).toEqual([]); + expect(normalizeMatrixBlockTypes('')).toEqual([]); + expect(normalizeMatrixBlockTypes({ name: 'quote' })).toEqual([]); + }); + + it('fills every default for a member with nothing in it, fields included', () => { + expect(normalizeMatrixBlockTypes([{}])).toEqual([{ name: '', displayName: '', fields: [] }]); + }); + + it('coalesces a non-array fields to empty rather than propagating it', () => { + expect(normalizeMatrixBlockTypes([{ name: 'quote', displayName: 'Quote', fields: 'nope' }])).toEqual([ + { name: 'quote', displayName: 'Quote', fields: [] }, + ]); + }); + + it('passes a fully-populated camelCase block type through', () => { + const stored = [ + { + name: 'quote', + displayName: 'Quote', + fields: [ + { + name: 'text', + displayName: 'Text', + description: undefined, + fieldTypeName: 'Text', + required: true, + configuration: { 'Text.CharLimit': 120 }, + }, + ], + }, + ]; + + expect(normalizeMatrixBlockTypes(stored)).toEqual(stored); + }); + + it('reads a PascalCase block type - nested Fields included - into the same camelCase result', () => { + // What a Matrix field authored server-side from `new MatrixConfiguration { BlockTypes = ... }` + // looks like after EF Core serializes it with System.Text.Json's default options. + const stored = [ + { + Name: 'quote', + DisplayName: 'Quote', + Fields: [ + { + Name: 'text', + DisplayName: 'Text', + FieldTypeName: 'Text', + Required: true, + Configuration: { 'Text.CharLimit': 120 }, + }, + ], + }, + ]; + + expect(normalizeMatrixBlockTypes(stored)).toEqual([ + { + name: 'quote', + displayName: 'Quote', + fields: [ + { + name: 'text', + displayName: 'Text', + description: undefined, + fieldTypeName: 'Text', + required: true, + configuration: { 'Text.CharLimit': 120 }, + }, + ], + }, + ]); + }); + + it('reads casings mixed across block types, and mixed within one', () => { + const normalized = normalizeMatrixBlockTypes([ + { Name: 'quote', DisplayName: 'Quote', Fields: [{ Name: 'text', FieldTypeName: 'Text' }] }, + { name: 'image', displayName: 'Image', fields: [] }, + // A block type named PascalCase whose own fields arrived camelCase, and vice versa. + { Name: 'video', displayName: 'Video', fields: [{ name: 'url', FieldTypeName: 'Text' }] }, + ]); + + expect(normalized.map(blockType => blockType.name)).toEqual(['quote', 'image', 'video']); + expect(normalized.map(blockType => blockType.displayName)).toEqual(['Quote', 'Image', 'Video']); + expect(normalized.map(blockType => blockType.fields.map(field => field.name))).toEqual([ + ['text'], + [], + ['url'], + ]); + }); +}); + +describe('normalizeMatrixBlockValues', () => { + it('reads anything that is not an array as empty', () => { + expect(normalizeMatrixBlockValues(undefined)).toEqual([]); + expect(normalizeMatrixBlockValues(null)).toEqual([]); + expect(normalizeMatrixBlockValues('')).toEqual([]); + }); + + it('fills every default for a member with nothing in it', () => { + expect(normalizeMatrixBlockValues([{}, null])).toEqual([ + { blockTypeName: '', values: {} }, + { blockTypeName: '', values: {} }, + ]); + }); + + it('passes a stored block instance through', () => { + const stored = [{ blockTypeName: 'quote', values: { text: 'hello', author: 'nobody' } }]; + expect(normalizeMatrixBlockValues(stored)).toEqual(stored); + }); + + it('reads a PascalCase block instance into the same camelCase result', () => { + const stored = [{ BlockTypeName: 'quote', Values: { text: 'hello', author: 'nobody' } }]; + + expect(normalizeMatrixBlockValues(stored)).toEqual([ + { blockTypeName: 'quote', values: { text: 'hello', author: 'nobody' } }, + ]); + }); + + it('reads casings mixed across instances, and mixed within one', () => { + expect( + normalizeMatrixBlockValues([ + { BlockTypeName: 'quote', Values: { text: 'hello' } }, + { blockTypeName: 'image', values: { alt: 'nothing' } }, + { BlockTypeName: 'video', values: { url: 'https://example.test' } }, + ]), + ).toEqual([ + { blockTypeName: 'quote', values: { text: 'hello' } }, + { blockTypeName: 'image', values: { alt: 'nothing' } }, + { blockTypeName: 'video', values: { url: 'https://example.test' } }, + ]); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-block-type.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-block-type.ts new file mode 100644 index 00000000..6eaa6592 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-block-type.ts @@ -0,0 +1,64 @@ +import type { InlineFieldDefinition } from '../inline-field-definition'; +import { normalizeInlineFieldDefinitions } from '../inline-field-definition'; + +/** One block type a `Matrix` field's configuration declares. Mirrors `MatrixBlockType`. */ +export interface MatrixBlockType { + name: string; + displayName: string; + fields: InlineFieldDefinition[]; +} + +/** One block instance - what a `Matrix` field's value is a list of. Mirrors `MatrixBlockValue`. */ +export interface MatrixBlockValue { + blockTypeName: string; + values: Record; +} + +/** A stored block type in either casing - see {@link InlineFieldDefinition} for why both are read. */ +type RawMatrixBlockType = Partial<{ + name: string; + Name: string; + displayName: string; + DisplayName: string; + fields: unknown; + Fields: unknown; +}>; + +/** A stored block instance in either casing - see {@link InlineFieldDefinition} for why both are read. */ +type RawMatrixBlockValue = Partial<{ + blockTypeName: string; + BlockTypeName: string; + values: Record; + Values: Record; +}>; + +/** Reads a stored `Matrix.BlockTypes` configuration value, defensively. */ +export function normalizeMatrixBlockTypes(source: unknown): MatrixBlockType[] { + if (!Array.isArray(source)) { + return []; + } + + return source.map((item: unknown) => { + const value = (item ?? {}) as RawMatrixBlockType; + return { + name: value.name ?? value.Name ?? '', + displayName: value.displayName ?? value.DisplayName ?? '', + fields: normalizeInlineFieldDefinitions(value.fields ?? value.Fields), + }; + }); +} + +/** Reads a stored Matrix field's value - a list of block instances - defensively. */ +export function normalizeMatrixBlockValues(source: unknown): MatrixBlockValue[] { + if (!Array.isArray(source)) { + return []; + } + + return source.map((item: unknown) => { + const value = (item ?? {}) as RawMatrixBlockValue; + return { + blockTypeName: value.blockTypeName ?? value.BlockTypeName ?? '', + values: value.values ?? value.Values ?? {}, + }; + }); +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.html b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.html new file mode 100644 index 00000000..3c76a665 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.html @@ -0,0 +1,153 @@ +
+
+
+ +
+
+
+ @for (blockType of blockTypes.controls; track blockType; let blockTypeIndex = $index) { + + } +
+
+
+ +
+
+ + +
+ @if (selectedBlockType; as blockType) { +
+
+
+ {{ blockTypeLabel(blockType) || ('FlexFields::Matrix:UntitledBlockType' | abpLocalization) }} +
+ +
+
+ + +
+
+ + +
+
+
+
+ @for (subField of fieldsOf(blockType).controls; track subField; let fieldIndex = $index) { + + } +
+
+
+ +
+ } @else { +
+ {{ 'FlexFields::Matrix:NoBlockTypesHint' | abpLocalization }} +
+ } +
+ + +
+ @if (selectedSubField; as subField) { +
+
+
+ {{ subFieldLabel(subField) || ('FlexFields::Matrix:UntitledField' | abpLocalization) }} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + @if (fieldTypeNameOf(subField)) { +
+ + } +
+ } @else { +
{{ 'FlexFields::Matrix:NoFieldSelectedHint' | abpLocalization }}
+ } +
+
+
+
diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.spec.ts new file mode 100644 index 00000000..926c07b9 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.spec.ts @@ -0,0 +1,247 @@ +import { FormGroup } from '@angular/forms'; +import { TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@abp/ng.core/testing'; +import { NgxValidateCoreModule } from '@ngx-validate/core'; +import { FlexFieldData } from '../../models'; +import { provideFlexFields } from '../../providers'; +import { COMPOSITE_NESTING_DEPTH, MAX_COMPOSITE_NESTING_DEPTH } from '../composite-nesting'; +import { MatrixConfigComponent } from './matrix-config.component'; + +function fieldData(overrides: Partial = {}): FlexFieldData { + return { + id: '1', + name: 'sections', + displayName: 'Sections', + fieldTypeName: 'Matrix', + configuration: {}, + ...overrides, + }; +} + +const STORED_BLOCK_TYPES = [ + { + name: 'quote', + displayName: 'Quote', + fields: [ + { + name: 'text', + displayName: 'Text', + fieldTypeName: 'Text', + required: true, + configuration: { 'Text.CharLimit': 120 }, + }, + ], + }, + { name: 'image', displayName: 'Image', fields: [] }, +]; + +function render(selected?: FlexFieldData) { + const entity = new FormGroup({}); + const fixture = TestBed.createComponent(MatrixConfigComponent); + fixture.componentRef.setInput('type', 'Matrix'); + fixture.componentRef.setInput('Entity', entity); + if (selected) { + fixture.componentRef.setInput('selected', selected); + } + fixture.detectChanges(); + return { fixture, entity, component: fixture.componentInstance }; +} + +describe('MatrixConfigComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule.withConfig(), NgxValidateCoreModule.forRoot()], + providers: [provideFlexFields()], + }); + }); + + it('starts a new field with no block types at all', () => { + // Unlike Select's one blank option there is no sensible block type to guess at, so nothing is + // seeded and there is nothing selected to show. + const { component, entity } = render(); + + expect(component.blockTypes.length).toBe(0); + expect(component.selectedBlockTypeIndex).toBeNull(); + expect(component.selectedSubFieldIndex).toBeNull(); + expect(entity.get(['configuration', 'Matrix.BlockTypes'])).toBeTruthy(); + }); + + it('patches stored Matrix.BlockTypes into the form arrays, sub-fields included', () => { + const { component } = render(fieldData({ configuration: { 'Matrix.BlockTypes': STORED_BLOCK_TYPES } })); + + expect(component.blockTypes.length).toBe(2); + expect(component.blockTypes.at(0).value.name).toBe('quote'); + expect(component.blockTypes.at(0).value.displayName).toBe('Quote'); + expect(component.blockTypes.at(1).value.fields).toEqual([]); + + const fields = component.fieldsOf(component.blockTypes.at(0)); + expect(fields.length).toBe(1); + expect(fields.at(0).value).toMatchObject({ + name: 'text', + displayName: 'Text', + description: '', + fieldTypeName: 'Text', + required: true, + }); + }); + + it('hands each sub-field its stored configuration to the recursively-mounted editor', () => { + const { component } = render(fieldData({ configuration: { 'Matrix.BlockTypes': STORED_BLOCK_TYPES } })); + + // The nested replaced the seeded `configuration` control with Text's own + // group and patched the stored value into it - that round trip is the whole recursion mechanism. + const subField = component.fieldsOf(component.blockTypes.at(0)).at(0); + expect(subField.get('configuration')!.value['Text.CharLimit']).toBe(120); + }); + + it('patches a PascalCase stored configuration in just the same way', () => { + // A Matrix field seeded server-side from the typed C# configuration classes comes back PascalCase + // (EF Core serializes the value converter's JSON with System.Text.Json's default options), so the + // designer has to load it too - otherwise the block types render as if there were none. + const { component } = render( + fieldData({ + configuration: { + 'Matrix.BlockTypes': [ + { + Name: 'quote', + DisplayName: 'Quote', + Fields: [ + { + Name: 'text', + DisplayName: 'Text', + FieldTypeName: 'Text', + Required: true, + Configuration: { 'Text.CharLimit': 120 }, + }, + ], + }, + { Name: 'image', DisplayName: 'Image', Fields: [] }, + ], + }, + }), + ); + + expect(component.blockTypes.length).toBe(2); + expect(component.blockTypes.at(0).value.name).toBe('quote'); + expect(component.blockTypes.at(1).value.name).toBe('image'); + + const fields = component.fieldsOf(component.blockTypes.at(0)); + expect(fields.length).toBe(1); + expect(fields.at(0).value).toMatchObject({ name: 'text', fieldTypeName: 'Text', required: true }); + expect(fields.at(0).get('configuration')!.value['Text.CharLimit']).toBe(120); + }); + + it('selects the first block type and its first field after loading, not the last', () => { + const { component } = render(fieldData({ configuration: { 'Matrix.BlockTypes': STORED_BLOCK_TYPES } })); + + expect(component.selectedBlockTypeIndex).toBe(0); + expect(component.selectedSubFieldIndex).toBe(0); + }); + + it('does not leak configuration from a field of a different type', () => { + const { component } = render( + fieldData({ fieldTypeName: 'Text', configuration: { 'Matrix.BlockTypes': STORED_BLOCK_TYPES } }), + ); + + expect(component.blockTypes.length).toBe(0); + }); + + it('keeps the selection sane as block types are added and removed', () => { + const { component } = render(); + + component.addBlockType(); + expect(component.selectedBlockTypeIndex).toBe(0); + expect(component.selectedSubFieldIndex).toBeNull(); + + component.addBlockType(); + expect(component.blockTypes.length).toBe(2); + expect(component.selectedBlockTypeIndex).toBe(1); + + // Removing the selected, last block type falls back to the one before it. + component.removeBlockType(1); + expect(component.blockTypes.length).toBe(1); + expect(component.selectedBlockTypeIndex).toBe(0); + + component.removeBlockType(0); + expect(component.selectedBlockTypeIndex).toBeNull(); + expect(component.selectedSubFieldIndex).toBeNull(); + }); + + it('keeps the selection sane as a block type\'s own fields are added and removed', () => { + const { component } = render(); + const blockType = component.addBlockType(); + + component.addSubField(blockType); + component.addSubField(blockType); + expect(component.selectedSubFieldIndex).toBe(1); + + // Removing something above the selection shifts it down rather than leaving it pointing past the end. + component.removeSubField(blockType, 0); + expect(component.fieldsOf(blockType).length).toBe(1); + expect(component.selectedSubFieldIndex).toBe(0); + + component.removeSubField(blockType, 0); + expect(component.selectedSubFieldIndex).toBeNull(); + }); + + it('offers every registered type, composites included, to a top-level Matrix', () => { + const { component } = render(); + + expect(component.fieldTypeOptions.map(fieldType => fieldType.name)).toEqual([ + 'Text', + 'Number', + 'DateTime', + 'Select', + 'Boolean', + 'Tree', + 'Matrix', + 'Table', + ]); + }); + + describe('mounted at the nesting limit', () => { + beforeEach(() => { + // What an enclosing composite config editor would have provided: this Matrix's own sub-fields + // then land at MAX_COMPOSITE_NESTING_DEPTH, with no room left under them. + TestBed.configureTestingModule({ + providers: [{ provide: COMPOSITE_NESTING_DEPTH, useValue: MAX_COMPOSITE_NESTING_DEPTH - 1 }], + }); + }); + + it('stops offering composite types', () => { + const { component } = render(); + + expect(component.fieldTypeOptions.map(fieldType => fieldType.name)).toEqual([ + 'Text', + 'Number', + 'DateTime', + 'Select', + 'Boolean', + 'Tree', + ]); + }); + + it('still shows a composite the selected sub-field is already bound to', () => { + // Saving it fails on the server either way - CompositeFieldNesting is the constraint - but an + // empty select would hide what the field actually is. + const { component } = render( + fieldData({ + configuration: { + 'Matrix.BlockTypes': [ + { + name: 'quote', + displayName: 'Quote', + fields: [ + { name: 'rows', displayName: 'Rows', fieldTypeName: 'Table', required: false, configuration: {} }, + ], + }, + ], + }, + }), + ); + + expect(component.fieldTypeOptions.map(fieldType => fieldType.name)).toContain('Table'); + expect(component.fieldTypeOptions.map(fieldType => fieldType.name)).not.toContain('Matrix'); + }); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.ts new file mode 100644 index 00000000..3c2a3b62 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-config.component.ts @@ -0,0 +1,290 @@ +import { CoreModule } from '@abp/ng.core'; +import { Component, inject } from '@angular/core'; +import { + AbstractControl, + FormArray, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { FlexFieldConfigComponent } from '../../components/flex-field-config.component'; +import type { FlexFieldData } from '../../models'; +import { + COMPOSITE_NESTING_DEPTH, + allowsCompositeAt, + nextCompositeNestingDepth, +} from '../composite-nesting'; +import { FieldTypeConfigBase } from '../field-type-config-base'; +import type { FieldTypeDefinition } from '../field-type-definition'; +import { FieldTypeResolver } from '../field-type-resolver.service'; +import type { InlineFieldDefinition } from '../inline-field-definition'; +import { MatrixConfiguration } from './matrix-configuration'; +import { normalizeMatrixBlockTypes } from './matrix-block-type'; + +let nextInstanceId = 0; + +/** + * Designer-side editor for a `Matrix` field's configuration: the block-type schema. Block types, each + * with its own field list - but each sub-field's own type-specific configuration is delegated to a + * recursively-mounted ``, the same generic dispatch every top-level field's + * config editor already goes through. + * + * Three-pane master-detail, mirroring `TableConfigComponent`'s two-pane one level deeper: block types + * on the left select which block type's own editor renders in the middle - its own name/displayName + * plus *its* field list - and selecting a field there renders that field's full detail on the right. + * Without this, one field type with a nested config (Select's option list, or another Table/Matrix) + * would push every other field, of every other block type, down the page at once. + * + * Deliberately no drag-and-drop reordering in this first pass (`Select`'s own config editor has it, and + * the same `@angular/cdk/drag-drop` + `moveItemInArray` pattern would apply here at two levels - block + * types, and each block type's own fields) - add/remove is enough to be usable, reordering is a + * reasonable follow-up rather than something this change needs to ship complete. + */ +@Component({ + selector: 'ff-matrix-config', + templateUrl: './matrix-config.component.html', + imports: [CoreModule, ReactiveFormsModule, FlexFieldConfigComponent], + providers: [{ provide: COMPOSITE_NESTING_DEPTH, useFactory: nextCompositeNestingDepth }], +}) +export class MatrixConfigComponent extends FieldTypeConfigBase { + private readonly fieldTypeResolver = inject(FieldTypeResolver); + + /** The depth this matrix's own sub-fields live at - 2 for a top-level Matrix field. */ + private readonly subFieldDepth = inject(COMPOSITE_NESTING_DEPTH); + + /** + * Which registered field types are themselves composite, read straight off the registry - the + * client-side counterpart of the server's `ICompositeFieldType`, declared by whichever package ships + * each type (see `FieldTypeDefinition.composite`). Read once: registrations are fixed for the + * lifetime of the injector. + */ + private readonly compositeFieldTypeNames: ReadonlySet = new Set( + this.fieldTypeResolver + .getAll() + .filter(fieldType => fieldType.composite) + .map(fieldType => fieldType.name), + ); + + /** Seed value for each sub-field row's own recursively-mounted config editor, keyed by its FormGroup + * (a sub-field row carries no `configuration` control of its own until `` adds + * one, so the stored value has nowhere else to live between load and first render). + * + * Re-captured from the live form whenever a sub-field is deselected ({@link captureSeed}) - see + * `TableConfigComponent.columnSeeds` for why a stale seed here silently reverts the admin's edits. */ + private readonly subFieldSeeds = new WeakMap(); + + readonly instanceId = `matrix-config-${nextInstanceId++}`; + + /** Which block type's detail (middle + right pane) is shown - null only when there are no block types. */ + selectedBlockTypeIndex: number | null = null; + + /** Which of the *selected block type's* fields is shown on the right - null only when it has none. */ + selectedSubFieldIndex: number | null = null; + + /** + * The types a sub-field may be bound to - governed purely by nesting depth, exactly as + * `TableConfigComponent.fieldTypeOptions` is; see that one for why there is no "a Matrix sub-field + * cannot be a Matrix" special case and what the depth rule replaces it with. + */ + get fieldTypeOptions(): readonly FieldTypeDefinition[] { + if (allowsCompositeAt(this.subFieldDepth)) { + return this.fieldTypeResolver.getAll(); + } + + const boundFieldTypeName = this.selectedSubField ? this.fieldTypeNameOf(this.selectedSubField) : ''; + + return this.fieldTypeResolver + .getAll() + .filter( + fieldType => + !this.compositeFieldTypeNames.has(fieldType.name) || fieldType.name === boundFieldTypeName, + ); + } + + get blockTypes(): FormArray { + return this.configuration.controls['Matrix.BlockTypes'] as FormArray; + } + + get selectedBlockType(): FormGroup | undefined { + return this.selectedBlockTypeIndex !== null + ? (this.blockTypes.at(this.selectedBlockTypeIndex) as FormGroup) + : undefined; + } + + get selectedSubField(): FormGroup | undefined { + const blockType = this.selectedBlockType; + return blockType && this.selectedSubFieldIndex !== null + ? (this.fieldsOf(blockType).at(this.selectedSubFieldIndex) as FormGroup) + : undefined; + } + + protected configurationDefaults(): object { + return new MatrixConfiguration(); + } + + protected override onConfigurationPatched(): void { + const stored = normalizeMatrixBlockTypes(this.selectedField?.configuration['Matrix.BlockTypes']); + + stored.forEach(blockType => { + const group = this.addBlockType(); + group.patchValue({ name: blockType.name, displayName: blockType.displayName }); + blockType.fields.forEach(field => this.addSubField(group, field)); + }); + + // `addBlockType`/`addSubField` leave the *last* loaded block type and field selected - default to + // the first of each instead, the same "show something meaningful" convention as Table's own columns. + if (this.blockTypes.length > 0) { + this.selectBlockType(0); + } + } + + protected override onConfigurationReset(): void { + // A field being created starts with no block types - unlike Select's one blank option, there is no + // sensible default block type to guess at, so the admin adds them explicitly. + } + + addBlockType(): FormGroup { + const group = new FormGroup({ + name: new FormControl('', Validators.required), + displayName: new FormControl('', Validators.required), + fields: new FormArray([]), + }); + + // Adding deselects whatever was open, so its live configuration has to be snapshotted first. + this.captureSeed(); + this.blockTypes.push(group); + this.selectedBlockTypeIndex = this.blockTypes.length - 1; + this.selectedSubFieldIndex = null; + return group; + } + + removeBlockType(index: number): void { + this.blockTypes.removeAt(index); + + if (this.blockTypes.length === 0) { + this.selectedBlockTypeIndex = null; + this.selectedSubFieldIndex = null; + } else if (this.selectedBlockTypeIndex !== null) { + if (index < this.selectedBlockTypeIndex) { + this.selectedBlockTypeIndex -= 1; + } else if (index === this.selectedBlockTypeIndex) { + this.selectBlockType(Math.min(index, this.blockTypes.length - 1)); + } + } + } + + selectBlockType(index: number): void { + // Switching block type unmounts the currently-shown sub-field's editor too, so its live + // configuration has to be snapshotted here as well, not just in `selectSubField`. + this.captureSeed(); + this.selectedBlockTypeIndex = index; + const fields = this.fieldsOf(this.blockTypes.at(index)); + this.selectedSubFieldIndex = fields.length > 0 ? 0 : null; + } + + selectSubField(index: number): void { + this.captureSeed(); + this.selectedSubFieldIndex = index; + } + + /** + * Snapshots the currently-selected sub-field's *current* nested configuration into + * {@link subFieldSeeds}, so re-selecting it re-seeds from what the admin last had on screen rather + * than from what was loaded off the server. Writes a new object only on deselection, never per + * change-detection cycle - see `TableConfigComponent.captureSeed` for why that matters. + */ + private captureSeed(): void { + const subField = this.selectedSubField; + const configuration = subField?.get('configuration')?.value as Record | undefined; + if (!subField || !configuration) { + return; + } + + this.subFieldSeeds.set(subField, { + id: '', + name: subField.get('name')?.value ?? '', + displayName: subField.get('displayName')?.value ?? '', + description: subField.get('description')?.value ?? undefined, + fieldTypeName: this.fieldTypeNameOf(subField), + configuration, + }); + } + + fieldsOf(blockType: AbstractControl): FormArray { + return (blockType as FormGroup).controls['fields'] as FormArray; + } + + addSubField(blockType: FormGroup, seed?: InlineFieldDefinition): FormGroup { + const group = new FormGroup({ + name: new FormControl(seed?.name ?? '', Validators.required), + displayName: new FormControl(seed?.displayName ?? '', Validators.required), + description: new FormControl(seed?.description ?? ''), + fieldTypeName: new FormControl(seed?.fieldTypeName ?? '', Validators.required), + required: new FormControl(seed?.required ?? false), + // Carries the stored configuration for a sub-field the admin never opens - see the identical + // control in `TableConfigComponent.addColumn` for why leaving it out loses data on save. + configuration: new FormControl(seed?.configuration ?? {}), + }); + + this.subFieldSeeds.set( + group, + seed + ? { + id: '', + name: seed.name, + displayName: seed.displayName, + description: seed.description, + fieldTypeName: seed.fieldTypeName, + configuration: seed.configuration, + } + : undefined, + ); + + // Adding deselects whatever was open, so its live configuration has to be snapshotted first. + this.captureSeed(); + this.fieldsOf(blockType).push(group); + this.selectedSubFieldIndex = this.fieldsOf(blockType).length - 1; + return group; + } + + removeSubField(blockType: AbstractControl, index: number): void { + this.fieldsOf(blockType).removeAt(index); + + const fields = this.fieldsOf(blockType); + if (fields.length === 0) { + this.selectedSubFieldIndex = null; + } else if (this.selectedSubFieldIndex !== null) { + if (index < this.selectedSubFieldIndex) { + this.selectedSubFieldIndex -= 1; + } else if (index === this.selectedSubFieldIndex) { + this.selectedSubFieldIndex = Math.min(index, fields.length - 1); + } + } + } + + fieldTypeNameOf(subField: AbstractControl): string { + return (subField as FormGroup).get('fieldTypeName')?.value ?? ''; + } + + /** Localization key for a sub-field's field type, for the field list's subtitle. */ + fieldTypeDisplayNameKeyOf(subField: AbstractControl): string { + const fieldTypeName = this.fieldTypeNameOf(subField); + return this.fieldTypeResolver.find(fieldTypeName)?.displayNameKey ?? fieldTypeName; + } + + /** Label for a block type in the left-hand list - falls back to its name, then blank. */ + blockTypeLabel(blockType: FormGroup): string { + return (blockType.get('displayName')?.value || blockType.get('name')?.value || '').trim(); + } + + /** Label for a sub-field in the field list - falls back to its name, then blank. */ + subFieldLabel(subField: AbstractControl): string { + const group = subField as FormGroup; + return (group.get('displayName')?.value || group.get('name')?.value || '').trim(); + } + + subFieldSeedOf(subField: AbstractControl): FlexFieldData | undefined { + return this.subFieldSeeds.get(subField as FormGroup); + } +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-configuration.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-configuration.ts new file mode 100644 index 00000000..d79e6b84 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-configuration.ts @@ -0,0 +1,9 @@ +import { FormArray } from '@angular/forms'; + +/** + * Configuration of a `Matrix` field, shaped for `FormBuilder.group()`. Mirrors `MatrixConfiguration` + * (`src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Matrix/MatrixConfiguration.cs`). + */ +export class MatrixConfiguration { + 'Matrix.BlockTypes': unknown = new FormArray([]); +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.html b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.html new file mode 100644 index 00000000..af0914ad --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.html @@ -0,0 +1,54 @@ +
+
+
+ +
+
+
+ {{ blockTypeOf(block)?.displayName ?? blockTypeOf(block)?.name }} +
+ + +
+
+
+ + +
+ {{ message }} +
+
+
+
+
+
+ +
+ + {{ 'AbpValidation::ThisFieldIsRequired' | abpLocalization }} + + + {{ fieldValue.field.description }} + +
+
+
diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.spec.ts new file mode 100644 index 00000000..17dc8ba7 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.spec.ts @@ -0,0 +1,148 @@ +import { FormArray, FormGroup } from '@angular/forms'; +import { TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@abp/ng.core/testing'; +import { NgxValidateCoreModule } from '@ngx-validate/core'; +import { FlexFieldValue } from '../../models'; +import { provideFlexFields } from '../../providers'; +import { TextMode } from '../text'; +import { MatrixControlComponent } from './matrix-control.component'; + +const BLOCK_TYPES = [ + { + name: 'quote', + displayName: 'Quote', + fields: [ + { + name: 'text', + displayName: 'Text', + fieldTypeName: 'Text', + required: false, + configuration: { 'Text.Mode': TextMode.SingleLine }, + }, + ], + }, + { name: 'divider', displayName: 'Divider', fields: [] }, +]; + +function fieldValue(overrides: Partial = {}): FlexFieldValue { + return { + field: { + id: '1', + name: 'sections', + displayName: 'Sections', + fieldTypeName: 'Matrix', + configuration: { 'Matrix.BlockTypes': BLOCK_TYPES }, + }, + required: false, + searchable: false, + ...overrides, + }; +} + +function render(field: FlexFieldValue, selected?: unknown) { + const values = new FormGroup({}); + const entity = new FormGroup({ flexFields: values }); + const fixture = TestBed.createComponent(MatrixControlComponent); + fixture.componentRef.setInput('fields', field); + fixture.componentRef.setInput('entity', entity); + fixture.componentRef.setInput('parentFieldName', 'flexFields'); + if (selected !== undefined) { + fixture.componentRef.setInput('selected', selected); + } + fixture.detectChanges(); + return { fixture, values, component: fixture.componentInstance }; +} + +describe('MatrixControlComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule.withConfig(), NgxValidateCoreModule.forRoot()], + providers: [provideFlexFields()], + }); + }); + + it('binds a FormArray, not a scalar control', () => { + const { values } = render(fieldValue()); + expect(values.get('sections')).toBeInstanceOf(FormArray); + expect(values.get('sections')!.value).toEqual([]); + }); + + it('renders the configured block types as add buttons', () => { + const { component, fixture } = render(fieldValue()); + + expect(component.blockTypes.map(blockType => blockType.name)).toEqual(['quote', 'divider']); + expect(fixture.nativeElement.textContent).toContain('Quote'); + expect(fixture.nativeElement.textContent).toContain('Divider'); + }); + + it('renders stored blocks and recursively mounts each sub-field control', () => { + const { component, values } = render(fieldValue(), [ + { blockTypeName: 'quote', values: { text: 'hello' } }, + { blockTypeName: 'divider', values: {} }, + ]); + + expect(component.blocks.length).toBe(2); + + // The recursion put the stored sub-field value on a real control inside the block's own group. + const block = component.blocks.at(0); + expect(component.valuesGroupOf(block).get('text')!.value).toBe('hello'); + expect(values.get('sections')!.value).toEqual([ + { blockTypeName: 'quote', values: { text: 'hello' } }, + { blockTypeName: 'divider', values: {} }, + ]); + }); + + it('grows the value the form emits when a block is added', () => { + const { component, values, fixture } = render(fieldValue(), [ + { blockTypeName: 'quote', values: { text: 'hello' } }, + ]); + + component.addBlock(component.blockTypes[0]); + fixture.detectChanges(); + + expect(component.blocks.length).toBe(2); + expect(values.get('sections')!.value).toEqual([ + { blockTypeName: 'quote', values: { text: 'hello' } }, + { blockTypeName: 'quote', values: { text: '' } }, + ]); + }); + + it('shrinks it again when a block is removed', () => { + const { component, values, fixture } = render(fieldValue(), [ + { blockTypeName: 'quote', values: { text: 'hello' } }, + { blockTypeName: 'divider', values: {} }, + ]); + + component.removeBlock(0); + fixture.detectChanges(); + + expect(values.get('sections')!.value).toEqual([{ blockTypeName: 'divider', values: {} }]); + }); + + it('starts a newly added block expanded and a loaded one collapsed', () => { + // UI state only - never written into the value. + const { component, fixture } = render(fieldValue(), [{ blockTypeName: 'quote', values: {} }]); + expect(component.isExpanded(component.blocks.at(0))).toBe(false); + + component.addBlock(component.blockTypes[0]); + fixture.detectChanges(); + expect(component.isExpanded(component.blocks.at(1))).toBe(true); + + component.toggleExpanded(component.blocks.at(1)); + expect(component.isExpanded(component.blocks.at(1))).toBe(false); + }); + + it('marks a required Matrix invalid until it holds at least one block', () => { + const { component, values, fixture } = render(fieldValue({ required: true })); + expect(values.get('sections')!.errors).toEqual({ required: true }); + + component.addBlock(component.blockTypes[0]); + fixture.detectChanges(); + expect(values.get('sections')!.errors).toBeNull(); + }); + + it('reads a stored value that is not an array as no blocks at all', () => { + const { component } = render(fieldValue(), 'not-an-array'); + expect(component.blocks.length).toBe(0); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.ts new file mode 100644 index 00000000..5f14e9cb --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-control.component.ts @@ -0,0 +1,161 @@ +import { CoreModule, LocalizationService } from '@abp/ng.core'; +import { CommonModule } from '@angular/common'; +import { + AbstractControl, + FormArray, + FormControl, + FormGroup, + ReactiveFormsModule, + ValidatorFn, +} from '@angular/forms'; +import { Component, inject } from '@angular/core'; +import { FlexFieldControlComponent } from '../../components/flex-field-control.component'; +import type { FlexFieldValue } from '../../models'; +import { flexFieldErrorMessage } from '../../utils'; +import { FieldTypeControlBase } from '../field-type-control-base'; +import type { InlineFieldDefinition } from '../inline-field-definition'; +import { MatrixConfiguration } from './matrix-configuration'; +import type { MatrixBlockType } from './matrix-block-type'; +import { normalizeMatrixBlockTypes, normalizeMatrixBlockValues } from './matrix-block-type'; + +/** + * Edits the value of a `Matrix` field: a `FormArray` of block instances, each an occurrence of one of + * the block types `Matrix.BlockTypes` declares. One "add" button per configured block type (not a + * single generic "add row" button) is the clearest UX difference from a plain repeatable table. + * + * Each block recursively mounts `` per sub-field - the same generic dispatch a + * top-level field goes through to reach this component in the first place, just invoked one level + * deeper with the block instance's own `values` group as the new `entity`. + */ +@Component({ + selector: 'ff-matrix-control', + templateUrl: './matrix-control.component.html', + imports: [CoreModule, CommonModule, ReactiveFormsModule, FlexFieldControlComponent], +}) +export class MatrixControlComponent extends FieldTypeControlBase { + private readonly localization = inject(LocalizationService); + + /** Which block instances currently show their fields. UI state only - never written into the value. */ + private readonly expandedBlocks = new Set(); + + /** Each block instance's stored sub-field values, keyed by its FormGroup - `values` itself starts + * empty and is populated one control at a time as each `` mounts, so the + * original stored dictionary has to live somewhere else for `[selected]` to read from. */ + private readonly blockValueSeeds = new WeakMap>(); + + /** `subFieldValueOf` is called from the template on every change-detection cycle, and + * `` only keeps its mounted child alive across a cycle if `[fields]` is + * reference-equal to what it rendered last time - a fresh object literal per call defeats that and + * tears down/rebuilds the recursively-mounted control (losing focus and in-progress input) on every + * cycle, not just when the sub-field actually changes. `blockTypes` (hence each `subField`) is only + * replaced when `createControl()` reruns, so caching by `subField` identity is safe here. */ + private readonly subFieldValueCache = new WeakMap(); + + blockTypes: MatrixBlockType[] = []; + + get blocks(): FormArray { + return (this.fieldControl as FormArray) ?? this.fb.array([]); + } + + protected configurationDefaults(): object { + return new MatrixConfiguration(); + } + + protected createControl(): AbstractControl { + this.blockTypes = normalizeMatrixBlockTypes( + this.fieldValue?.field.configuration['Matrix.BlockTypes'], + ); + const stored = normalizeMatrixBlockValues(this.selectedValue); + + const validators: ValidatorFn[] = []; + if (this.fieldValue!.required) { + validators.push(control => ((control as FormArray).length > 0 ? null : { required: true })); + } + + const array = this.fb.array([], validators); + stored.forEach(block => array.push(this.buildBlockGroup(block.blockTypeName, block.values, false))); + return array; + } + + blockTypeOf(block: AbstractControl): MatrixBlockType | undefined { + const name = (block as FormGroup).get('blockTypeName')?.value; + return this.blockTypes.find(blockType => blockType.name === name); + } + + fieldsOf(block: AbstractControl): InlineFieldDefinition[] { + return this.blockTypeOf(block)?.fields ?? []; + } + + valuesGroupOf(block: AbstractControl): FormGroup { + return (block as FormGroup).get('values') as FormGroup; + } + + subFieldValueOf(subField: InlineFieldDefinition): FlexFieldValue { + let value = this.subFieldValueCache.get(subField); + if (!value) { + value = { + field: { + id: '', + name: subField.name, + displayName: subField.displayName, + description: subField.description, + fieldTypeName: subField.fieldTypeName, + configuration: subField.configuration, + }, + required: subField.required, + searchable: false, + }; + this.subFieldValueCache.set(subField, value); + } + return value; + } + + selectedValueOf(block: AbstractControl, subField: InlineFieldDefinition): unknown { + return this.blockValueSeeds.get(block as FormGroup)?.[subField.name]; + } + + subFieldErrorMessage(block: AbstractControl, subField: InlineFieldDefinition): string | null { + return flexFieldErrorMessage(this.valuesGroupOf(block).get(subField.name), this.localization); + } + + addBlock(blockType: MatrixBlockType): void { + this.blocks.push(this.buildBlockGroup(blockType.name, {}, true)); + } + + removeBlock(index: number): void { + const group = this.blocks.at(index); + this.blocks.removeAt(index); + this.expandedBlocks.delete(group); + } + + isExpanded(block: AbstractControl): boolean { + return this.expandedBlocks.has(block); + } + + toggleExpanded(block: AbstractControl): void { + if (this.expandedBlocks.has(block)) { + this.expandedBlocks.delete(block); + } else { + this.expandedBlocks.add(block); + } + } + + private buildBlockGroup( + blockTypeName: string, + values: Record, + startExpanded: boolean, + ): FormGroup { + const group = new FormGroup({ + blockTypeName: new FormControl(blockTypeName), + values: new FormGroup({}), + }); + + this.blockValueSeeds.set(group, values); + + if (startExpanded) { + this.expandedBlocks.add(group); + } + + return group; + } +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.html b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.html new file mode 100644 index 00000000..7aedca1e --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.html @@ -0,0 +1,24 @@ +@if (showInList) { + {{ blocks.length === 0 ? '-' : blocks.length + ' block(s)' }} +} @else { +
+ {{ fields.field.displayName }} + @if (blocks.length === 0) { + - + } @else { +
+
+
{{ blockTypeOf(block)?.displayName ?? block.blockTypeName }}
+
+ +
+
+
+ } +
+} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.spec.ts new file mode 100644 index 00000000..00ac133f --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.spec.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@abp/ng.core/testing'; +import { FlexFieldValue } from '../../models'; +import { provideFlexFields } from '../../providers'; +import { MatrixViewComponent } from './matrix-view.component'; + +const BLOCK_TYPES = [ + { + name: 'quote', + displayName: 'Quote', + fields: [ + { name: 'text', displayName: 'Text', fieldTypeName: 'Text', required: false, configuration: {} }, + ], + }, +]; + +function fieldValue(overrides: Partial = {}): FlexFieldValue { + return { + field: { + id: '1', + name: 'sections', + displayName: 'Sections', + fieldTypeName: 'Matrix', + configuration: { 'Matrix.BlockTypes': BLOCK_TYPES }, + }, + required: false, + searchable: false, + ...overrides, + }; +} + +function render(fields: FlexFieldValue, value: unknown, showInList = false) { + const fixture = TestBed.createComponent(MatrixViewComponent); + fixture.componentRef.setInput('fields', fields); + fixture.componentRef.setInput('type', 'Matrix'); + fixture.componentRef.setInput('value', value); + fixture.componentRef.setInput('showInList', showInList); + fixture.detectChanges(); + return fixture; +} + +describe('MatrixViewComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule.withConfig()], + providers: [provideFlexFields()], + }); + }); + + it('renders each block under its block type display name', () => { + const fixture = render(fieldValue(), [{ blockTypeName: 'quote', values: { text: 'hello' } }]); + + expect(fixture.nativeElement.textContent).toContain('Quote'); + }); + + it('recurses into for each sub-field value', () => { + const fixture = render(fieldValue(), [{ blockTypeName: 'quote', values: { text: 'hello' } }]); + + // 'hello' only reaches the DOM if the sub-field's own registered view component rendered it. + expect(fixture.nativeElement.textContent).toContain('hello'); + }); + + it('falls back to the stored block type name when the type is no longer configured', () => { + const fixture = render(fieldValue(), [{ blockTypeName: 'removed', values: {} }]); + + expect(fixture.nativeElement.textContent).toContain('removed'); + }); + + it('shows a dash for an empty or unreadable value', () => { + expect(render(fieldValue(), []).nativeElement.textContent).toContain('-'); + expect(render(fieldValue(), undefined).nativeElement.textContent).toContain('-'); + }); + + it('collapses to a block count in list mode', () => { + const fixture = render( + fieldValue(), + [ + { blockTypeName: 'quote', values: { text: 'a' } }, + { blockTypeName: 'quote', values: { text: 'b' } }, + ], + true, + ); + + expect(fixture.nativeElement.textContent.trim()).toBe('2 block(s)'); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.ts new file mode 100644 index 00000000..7a4468ee --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/matrix/matrix-view.component.ts @@ -0,0 +1,76 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnChanges } from '@angular/core'; +import { FlexFieldViewComponent } from '../../components/flex-field-view.component'; +import type { FlexFieldValue } from '../../models'; +import type { InlineFieldDefinition } from '../inline-field-definition'; +import type { MatrixBlockType, MatrixBlockValue } from './matrix-block-type'; +import { normalizeMatrixBlockTypes, normalizeMatrixBlockValues } from './matrix-block-type'; + +/** + * Displays the value of a `Matrix` field read-only: iterates block instances and, for each sub-field, + * recursively invokes `` - reusing the existing top-level dispatcher one level + * deeper rather than hand-writing rendering for every field type that might show up inside a block. + */ +@Component({ + selector: 'ff-matrix-view', + templateUrl: './matrix-view.component.html', + imports: [CommonModule, FlexFieldViewComponent], +}) +export class MatrixViewComponent implements OnChanges { + @Input() showInList = false; + + @Input() fields?: FlexFieldValue; + + /** Registration key of the field type - always `Matrix` here. */ + @Input() type?: string; + + @Input() value: unknown = ''; + + blocks: MatrixBlockValue[] = []; + blockTypes: MatrixBlockType[] = []; + + /** `subFieldValueOf` is called from the template on every change-detection cycle, and + * `` only keeps its mounted child alive across a cycle if `[fields]` is + * reference-equal to what it rendered last time - a fresh object literal per call defeats that and + * tears down/rebuilds every recursively-mounted view on every cycle, not just when the value actually + * changes. Keyed on `block` (outer) then sub-field name (inner): `ngOnChanges` replaces `this.blocks` + * wholesale on a real value change, so stale entries for old block objects simply stop being reachable + * rather than needing explicit invalidation. */ + private readonly subFieldValueCache = new WeakMap>(); + + ngOnChanges(): void { + this.blocks = normalizeMatrixBlockValues(this.value); + this.blockTypes = normalizeMatrixBlockTypes(this.fields?.field.configuration['Matrix.BlockTypes']); + } + + blockTypeOf(block: MatrixBlockValue): MatrixBlockType | undefined { + return this.blockTypes.find(blockType => blockType.name === block.blockTypeName); + } + + subFieldValueOf(subField: InlineFieldDefinition, block: MatrixBlockValue): FlexFieldValue { + let blockCache = this.subFieldValueCache.get(block); + if (!blockCache) { + blockCache = new Map(); + this.subFieldValueCache.set(block, blockCache); + } + + let value = blockCache.get(subField.name); + if (!value) { + value = { + field: { + id: '', + name: subField.name, + displayName: subField.displayName, + description: subField.description, + fieldTypeName: subField.fieldTypeName, + configuration: subField.configuration, + }, + required: subField.required, + searchable: false, + value: block.values[subField.name], + }; + blockCache.set(subField.name, value); + } + return value; + } +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/index.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/index.ts new file mode 100644 index 00000000..affdc9d8 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/index.ts @@ -0,0 +1,5 @@ +export * from './table-config.component'; +export * from './table-configuration'; +export * from './table-control.component'; +export * from './table-row'; +export * from './table-view.component'; diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.html b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.html new file mode 100644 index 00000000..5174384a --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.html @@ -0,0 +1,102 @@ +
+
+
+ +
+
+
+ @for (column of columns.controls; track column; let columnIndex = $index) { + + } +
+
+
+ +
+
+ + +
+ @if (selectedColumn; as column) { +
+
+
+ {{ columnLabel(column) || ('FlexFields::Table:UntitledColumn' | abpLocalization) }} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + @if (fieldTypeNameOf(column)) { +
+ + } +
+ } @else { +
{{ 'FlexFields::Table:NoColumnsHint' | abpLocalization }}
+ } +
+
+
+
diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.spec.ts new file mode 100644 index 00000000..84e25c67 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.spec.ts @@ -0,0 +1,226 @@ +import { FormGroup } from '@angular/forms'; +import { TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@abp/ng.core/testing'; +import { NgxValidateCoreModule } from '@ngx-validate/core'; +import { FlexFieldData } from '../../models'; +import { provideFlexFields } from '../../providers'; +import { COMPOSITE_NESTING_DEPTH, MAX_COMPOSITE_NESTING_DEPTH } from '../composite-nesting'; +import { TableConfigComponent } from './table-config.component'; + +function fieldData(overrides: Partial = {}): FlexFieldData { + return { + id: '1', + name: 'specs', + displayName: 'Specs', + fieldTypeName: 'Table', + configuration: {}, + ...overrides, + }; +} + +const STORED_COLUMNS = [ + { + name: 'title', + displayName: 'Title', + fieldTypeName: 'Text', + required: true, + configuration: { 'Text.CharLimit': 120 }, + }, + { name: 'qty', displayName: 'Quantity', fieldTypeName: 'Number', required: false, configuration: {} }, +]; + +function render(selected?: FlexFieldData) { + const entity = new FormGroup({}); + const fixture = TestBed.createComponent(TableConfigComponent); + fixture.componentRef.setInput('type', 'Table'); + fixture.componentRef.setInput('Entity', entity); + if (selected) { + fixture.componentRef.setInput('selected', selected); + } + fixture.detectChanges(); + return { fixture, entity, component: fixture.componentInstance }; +} + +describe('TableConfigComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule.withConfig(), NgxValidateCoreModule.forRoot()], + providers: [provideFlexFields()], + }); + }); + + it('starts a new field with one blank column, not an empty schema', () => { + // The same "seed one row" convention SelectConfigComponent uses for its option list. Matrix does + // the opposite (no block types) because there is no sensible default block type to guess at. + const { component, entity } = render(); + + expect(component.columns.length).toBe(1); + expect(component.selectedColumnIndex).toBe(0); + expect(component.columns.at(0).value).toMatchObject({ + name: '', + displayName: '', + description: '', + fieldTypeName: '', + required: false, + }); + expect(entity.get(['configuration', 'Table.Columns'])).toBeTruthy(); + }); + + it('patches stored Table.Columns into the form array', () => { + const { component } = render(fieldData({ configuration: { 'Table.Columns': STORED_COLUMNS } })); + + expect(component.columns.length).toBe(2); + expect(component.columns.at(0).value).toMatchObject({ + name: 'title', + displayName: 'Title', + description: '', + fieldTypeName: 'Text', + required: true, + }); + expect(component.columns.at(1).value).toMatchObject({ name: 'qty', fieldTypeName: 'Number' }); + expect(component.selectedColumnIndex).toBe(0); + }); + + it('patches a PascalCase stored configuration in just the same way', () => { + // A Table field seeded server-side from the typed C# configuration classes comes back PascalCase + // (EF Core serializes the value converter's JSON with System.Text.Json's default options), so the + // designer has to load it too - otherwise the columns render as if there were none. + const { component } = render( + fieldData({ + configuration: { + 'Table.Columns': [ + { + Name: 'title', + DisplayName: 'Title', + FieldTypeName: 'Text', + Required: true, + Configuration: { 'Text.CharLimit': 120 }, + }, + { Name: 'qty', DisplayName: 'Quantity', FieldTypeName: 'Number', Required: false }, + ], + }, + }), + ); + + expect(component.columns.length).toBe(2); + expect(component.columns.at(0).value).toMatchObject({ + name: 'title', + displayName: 'Title', + fieldTypeName: 'Text', + required: true, + }); + expect(component.columns.at(1).value).toMatchObject({ name: 'qty', fieldTypeName: 'Number' }); + expect(component.columns.at(0).get('configuration')!.value['Text.CharLimit']).toBe(120); + }); + + it('hands each column its stored configuration to the recursively-mounted editor', () => { + const { component } = render(fieldData({ configuration: { 'Table.Columns': STORED_COLUMNS } })); + + // The nested replaced the seeded `configuration` control with Text's own + // group and patched the stored value into it - that round trip is the whole recursion mechanism. + expect(component.columns.at(0).get('configuration')!.value['Text.CharLimit']).toBe(120); + }); + + it('keeps an unopened column\'s stored configuration rather than dropping it on save', () => { + // Only the selected column mounts a nested editor; the second column's raw configuration has to + // survive on the seeded FormControl or saving would silently blank it. + const { component } = render( + fieldData({ + configuration: { + 'Table.Columns': [ + STORED_COLUMNS[0], + { ...STORED_COLUMNS[1], configuration: { 'Number.Decimals': 2 } }, + ], + }, + }), + ); + + expect(component.columns.at(1).get('configuration')!.value).toEqual({ 'Number.Decimals': 2 }); + }); + + it('does not leak configuration from a field of a different type', () => { + const { component } = render( + fieldData({ fieldTypeName: 'Text', configuration: { 'Table.Columns': STORED_COLUMNS } }), + ); + + expect(component.columns.length).toBe(1); + expect(component.columns.at(0).value.name).toBe(''); + }); + + it('keeps the selection sane as columns are added and removed', () => { + const { component } = render(); + expect(component.columns.length).toBe(1); + + component.addColumn(); + component.addColumn(); + expect(component.columns.length).toBe(3); + expect(component.selectedColumnIndex).toBe(2); + + // Removing something above the selection shifts it down rather than leaving it past the end. + component.removeColumn(0); + expect(component.selectedColumnIndex).toBe(1); + + // Removing the selected, last column falls back to the one before it. + component.removeColumn(1); + expect(component.selectedColumnIndex).toBe(0); + + component.removeColumn(0); + expect(component.columns.length).toBe(0); + expect(component.selectedColumnIndex).toBeNull(); + }); + + it('offers every registered type, composites included, to a top-level Table', () => { + const { component } = render(); + + expect(component.fieldTypeOptions.map(fieldType => fieldType.name)).toEqual([ + 'Text', + 'Number', + 'DateTime', + 'Select', + 'Boolean', + 'Tree', + 'Matrix', + 'Table', + ]); + }); + + describe('mounted at the nesting limit', () => { + beforeEach(() => { + // What an enclosing composite config editor would have provided: this Table's own columns then + // land at MAX_COMPOSITE_NESTING_DEPTH, with no room left under them. + TestBed.configureTestingModule({ + providers: [{ provide: COMPOSITE_NESTING_DEPTH, useValue: MAX_COMPOSITE_NESTING_DEPTH - 1 }], + }); + }); + + it('stops offering composite types', () => { + const { component } = render(); + + expect(component.fieldTypeOptions.map(fieldType => fieldType.name)).toEqual([ + 'Text', + 'Number', + 'DateTime', + 'Select', + 'Boolean', + 'Tree', + ]); + }); + + it('still shows a composite the selected column is already bound to', () => { + // Saving it fails on the server either way - CompositeFieldNesting is the constraint - but an + // empty select would hide what the column actually is. + const { component } = render( + fieldData({ + configuration: { + 'Table.Columns': [ + { name: 'blocks', displayName: 'Blocks', fieldTypeName: 'Matrix', required: false, configuration: {} }, + ], + }, + }), + ); + + expect(component.fieldTypeOptions.map(fieldType => fieldType.name)).toContain('Matrix'); + expect(component.fieldTypeOptions.map(fieldType => fieldType.name)).not.toContain('Table'); + }); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.ts new file mode 100644 index 00000000..1fd9897c --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-config.component.ts @@ -0,0 +1,229 @@ +import { CoreModule } from '@abp/ng.core'; +import { Component, inject } from '@angular/core'; +import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FlexFieldConfigComponent } from '../../components/flex-field-config.component'; +import type { FlexFieldData } from '../../models'; +import { + COMPOSITE_NESTING_DEPTH, + allowsCompositeAt, + nextCompositeNestingDepth, +} from '../composite-nesting'; +import { FieldTypeConfigBase } from '../field-type-config-base'; +import type { FieldTypeDefinition } from '../field-type-definition'; +import { FieldTypeResolver } from '../field-type-resolver.service'; +import type { InlineFieldDefinition } from '../inline-field-definition'; +import { normalizeInlineFieldDefinitions } from '../inline-field-definition'; +import { TableConfiguration } from './table-configuration'; + +let nextInstanceId = 0; + +/** + * Designer-side editor for a `Table` field's configuration: the one shared column schema. Simpler than + * `MatrixConfigComponent` - a single flat list instead of block types each with their own list - but + * built the same way: each column's own type-specific configuration is delegated to a + * recursively-mounted ``. + * + * Master-detail instead of a stacked list: the columns list on the left selects which column's own + * editor renders on the right, so opening a column with a nested type (its own `ff-flex-field-config`, + * e.g. Select's option list) doesn't push every other column down the page. + */ +@Component({ + selector: 'ff-table-config', + templateUrl: './table-config.component.html', + imports: [CoreModule, ReactiveFormsModule, FlexFieldConfigComponent], + providers: [{ provide: COMPOSITE_NESTING_DEPTH, useFactory: nextCompositeNestingDepth }], +}) +export class TableConfigComponent extends FieldTypeConfigBase { + private readonly fieldTypeResolver = inject(FieldTypeResolver); + + /** The depth this table's own columns live at - 2 for a top-level Table field. */ + private readonly columnDepth = inject(COMPOSITE_NESTING_DEPTH); + + /** + * Which registered field types are themselves composite, read straight off the registry - the + * client-side counterpart of the server's `ICompositeFieldType`, declared by whichever package ships + * each type (see `FieldTypeDefinition.composite`). Read once: registrations are fixed for the + * lifetime of the injector. + */ + private readonly compositeFieldTypeNames: ReadonlySet = new Set( + this.fieldTypeResolver + .getAll() + .filter(fieldType => fieldType.composite) + .map(fieldType => fieldType.name), + ); + + /** Seed value for each column's own recursively-mounted config editor, keyed by its FormGroup - see + * `MatrixConfigComponent`'s identical field for why this can't just live on the FormGroup itself. + * + * Re-captured from the live form whenever a column is deselected ({@link captureSeed}): only the + * selected column's `` is mounted, and `FieldTypeConfigBase` re-seeds the + * `configuration` group from this map every time it mounts - so a stale seed here silently reverts + * whatever the admin just typed into that column's nested editor. */ + private readonly columnSeeds = new WeakMap(); + + /** Distinguishes this instance's DOM ids from another Table config's - a Table column can be a Matrix + * whose own sub-field is a Table, so two of these can be on the page at once, and a plain + * `table-config-required-0` on both would make the inner checkbox's label toggle the outer one. */ + readonly instanceId = `table-config-${nextInstanceId++}`; + + /** Which column's detail is shown on the right - null only when the table has no columns at all. */ + selectedColumnIndex: number | null = null; + + /** + * The types a column may be bound to. Composite types drop out once there is no room left under a + * column for the fields *they* would declare - so at the current `MAX_COMPOSITE_NESTING_DEPTH` of 3, + * a top-level Table's columns may still be composite, but *their* own sub-fields may not. + * + * Note there is no "a Table column cannot be a Table" special case: self-nesting was never the thing + * worth blocking (`Table > Matrix > Table` sidestepped it and reached exactly the same shape), so + * depth is the single rule now, applied to every composite type alike. + * + * Whatever the selected column is *already* bound to stays in the list even when the rule would drop + * it, so a configuration stored before the limit existed still shows what it is rather than an empty + * select. Saving it still fails - `CompositeFieldNesting` is the constraint - which is the honest + * outcome. + */ + get fieldTypeOptions(): readonly FieldTypeDefinition[] { + if (allowsCompositeAt(this.columnDepth)) { + return this.fieldTypeResolver.getAll(); + } + + const boundFieldTypeName = this.selectedColumn ? this.fieldTypeNameOf(this.selectedColumn) : ''; + + return this.fieldTypeResolver + .getAll() + .filter( + fieldType => + !this.compositeFieldTypeNames.has(fieldType.name) || fieldType.name === boundFieldTypeName, + ); + } + + get columns(): FormArray { + return this.configuration.controls['Table.Columns'] as FormArray; + } + + get selectedColumn(): FormGroup | undefined { + return this.selectedColumnIndex !== null ? this.columns.at(this.selectedColumnIndex) : undefined; + } + + protected configurationDefaults(): object { + return new TableConfiguration(); + } + + protected override onConfigurationPatched(): void { + const stored = normalizeInlineFieldDefinitions(this.selectedField?.configuration['Table.Columns']); + stored.forEach(column => this.addColumn(column)); + this.selectedColumnIndex = this.columns.length > 0 ? 0 : null; + } + + protected override onConfigurationReset(): void { + // A field being created starts with one blank column, the same "seed one row" convention + // `SelectConfigComponent` uses for its own option list. + this.addColumn(); + } + + addColumn(seed?: InlineFieldDefinition): FormGroup { + const group = new FormGroup({ + name: new FormControl(seed?.name ?? '', Validators.required), + displayName: new FormControl(seed?.displayName ?? '', Validators.required), + description: new FormControl(seed?.description ?? ''), + fieldTypeName: new FormControl(seed?.fieldTypeName ?? '', Validators.required), + required: new FormControl(seed?.required ?? false), + // Carries the stored configuration for a column the admin never opens. Only the selected column + // mounts an ``, and that is what would otherwise add this control - so + // without seeding it here, saving after editing anything else drops every unopened column's own + // configuration. Replaced wholesale by `FieldTypeConfigBase` the moment the column is selected. + configuration: new FormControl(seed?.configuration ?? {}), + }); + + this.columnSeeds.set( + group, + seed + ? { + id: '', + name: seed.name, + displayName: seed.displayName, + description: seed.description, + fieldTypeName: seed.fieldTypeName, + configuration: seed.configuration, + } + : undefined, + ); + + // Adding deselects whatever was open, so its live configuration has to be snapshotted first. + this.captureSeed(this.selectedColumnIndex); + this.columns.push(group); + this.selectedColumnIndex = this.columns.length - 1; + return group; + } + + removeColumn(index: number): void { + this.columns.removeAt(index); + + if (this.columns.length === 0) { + this.selectedColumnIndex = null; + } else if (this.selectedColumnIndex !== null) { + if (index < this.selectedColumnIndex) { + this.selectedColumnIndex -= 1; + } else if (index === this.selectedColumnIndex) { + this.selectedColumnIndex = Math.min(index, this.columns.length - 1); + } + } + } + + selectColumn(index: number): void { + this.captureSeed(this.selectedColumnIndex); + this.selectedColumnIndex = index; + } + + /** + * Snapshots a column's *current* nested configuration into {@link columnSeeds}, so that re-selecting + * it re-seeds its editor from what the admin last had on screen rather than from what was loaded off + * the server. + * + * Deliberately writes a new object only here, on deselection - never per change-detection cycle. + * `columnSeedOf` feeds ``'s `[selected]` input, and a fresh object literal on + * every cycle would make that input look changed every cycle, tearing the nested editor down and + * rebuilding it mid-keystroke (the same reference-stability trap `TableControlComponent`'s + * `columnValueCache` exists for). + */ + private captureSeed(index: number | null): void { + if (index === null) { + return; + } + + const column = this.columns.at(index); + const configuration = column?.get('configuration')?.value as Record | undefined; + if (!column || !configuration) { + return; + } + + this.columnSeeds.set(column, { + id: '', + name: column.get('name')?.value ?? '', + displayName: column.get('displayName')?.value ?? '', + description: column.get('description')?.value ?? undefined, + fieldTypeName: this.fieldTypeNameOf(column), + configuration, + }); + } + + fieldTypeNameOf(column: FormGroup): string { + return column.get('fieldTypeName')?.value ?? ''; + } + + /** Localization key for a column's field type, for the left-hand list's subtitle. */ + fieldTypeDisplayNameKeyOf(column: FormGroup): string { + const fieldTypeName = this.fieldTypeNameOf(column); + return this.fieldTypeResolver.find(fieldTypeName)?.displayNameKey ?? fieldTypeName; + } + + /** Label for a column in the left-hand list - falls back to its name, then blank (caller shows a placeholder). */ + columnLabel(column: FormGroup): string { + return (column.get('displayName')?.value || column.get('name')?.value || '').trim(); + } + + columnSeedOf(column: FormGroup): FlexFieldData | undefined { + return this.columnSeeds.get(column); + } +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-configuration.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-configuration.ts new file mode 100644 index 00000000..5300bc0d --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-configuration.ts @@ -0,0 +1,9 @@ +import { FormArray } from '@angular/forms'; + +/** + * Configuration of a `Table` field, shaped for `FormBuilder.group()`. Mirrors `TableConfiguration` + * (`src/Dignite.Abp.FlexFields.Abstractions/Dignite/Abp/FlexFields/Table/TableConfiguration.cs`). + */ +export class TableConfiguration { + 'Table.Columns': unknown = new FormArray([]); +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.html b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.html new file mode 100644 index 00000000..320ce6c2 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.html @@ -0,0 +1,58 @@ +
+
+
+ +
+ + + + + + + + + + + + + +
{{ column.displayName }} + +
+ +
+ {{ message }} +
+
+ +
+
+ + {{ 'AbpValidation::ThisFieldIsRequired' | abpLocalization }} + + + {{ fieldValue.field.description }} + +
+
+
diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.spec.ts new file mode 100644 index 00000000..9f50fe4f --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.spec.ts @@ -0,0 +1,133 @@ +import { FormArray, FormGroup } from '@angular/forms'; +import { TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@abp/ng.core/testing'; +import { NgxValidateCoreModule } from '@ngx-validate/core'; +import { FlexFieldValue } from '../../models'; +import { provideFlexFields } from '../../providers'; +import { TextMode } from '../text'; +import { TableControlComponent } from './table-control.component'; + +const COLUMNS = [ + { + name: 'title', + displayName: 'Title', + fieldTypeName: 'Text', + required: false, + configuration: { 'Text.Mode': TextMode.SingleLine }, + }, + { + name: 'note', + displayName: 'Note', + fieldTypeName: 'Text', + required: false, + configuration: { 'Text.Mode': TextMode.SingleLine }, + }, +]; + +function fieldValue(overrides: Partial = {}): FlexFieldValue { + return { + field: { + id: '1', + name: 'specs', + displayName: 'Specs', + fieldTypeName: 'Table', + configuration: { 'Table.Columns': COLUMNS }, + }, + required: false, + searchable: false, + ...overrides, + }; +} + +function render(field: FlexFieldValue, selected?: unknown) { + const values = new FormGroup({}); + const entity = new FormGroup({ flexFields: values }); + const fixture = TestBed.createComponent(TableControlComponent); + fixture.componentRef.setInput('fields', field); + fixture.componentRef.setInput('entity', entity); + fixture.componentRef.setInput('parentFieldName', 'flexFields'); + if (selected !== undefined) { + fixture.componentRef.setInput('selected', selected); + } + fixture.detectChanges(); + return { fixture, values, component: fixture.componentInstance }; +} + +describe('TableControlComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule.withConfig(), NgxValidateCoreModule.forRoot()], + providers: [provideFlexFields()], + }); + }); + + it('binds a FormArray, not a scalar control', () => { + const { values } = render(fieldValue()); + expect(values.get('specs')).toBeInstanceOf(FormArray); + expect(values.get('specs')!.value).toEqual([]); + }); + + it('renders one header per configured column', () => { + const { component, fixture } = render(fieldValue()); + + expect(component.columns.map(column => column.name)).toEqual(['title', 'note']); + const headers = [...fixture.nativeElement.querySelectorAll('thead th')].map((th: HTMLElement) => + th.textContent!.trim(), + ); + expect(headers).toContain('Title'); + expect(headers).toContain('Note'); + }); + + it('renders stored rows and recursively mounts each column control', () => { + const { component, values } = render(fieldValue(), [ + { values: { title: 'One', note: 'first' } }, + { values: { title: 'Two', note: 'second' } }, + ]); + + expect(component.rows.length).toBe(2); + expect(component.valuesGroupOf(component.rows.at(0)).get('title')!.value).toBe('One'); + expect(values.get('specs')!.value).toEqual([ + { values: { title: 'One', note: 'first' } }, + { values: { title: 'Two', note: 'second' } }, + ]); + }); + + it('grows the value the form emits when a row is added', () => { + const { component, values, fixture } = render(fieldValue(), [{ values: { title: 'One', note: 'first' } }]); + + component.addRow(); + fixture.detectChanges(); + + expect(component.rows.length).toBe(2); + expect(values.get('specs')!.value).toEqual([ + { values: { title: 'One', note: 'first' } }, + { values: { title: '', note: '' } }, + ]); + }); + + it('shrinks it again when a row is removed', () => { + const { component, values, fixture } = render(fieldValue(), [ + { values: { title: 'One', note: 'first' } }, + { values: { title: 'Two', note: 'second' } }, + ]); + + component.removeRow(0); + fixture.detectChanges(); + + expect(values.get('specs')!.value).toEqual([{ values: { title: 'Two', note: 'second' } }]); + }); + + it('marks a required Table invalid until it holds at least one row', () => { + const { component, values, fixture } = render(fieldValue({ required: true })); + expect(values.get('specs')!.errors).toEqual({ required: true }); + + component.addRow(); + fixture.detectChanges(); + expect(values.get('specs')!.errors).toBeNull(); + }); + + it('reads a stored value that is not an array as no rows at all', () => { + const { component } = render(fieldValue(), 'not-an-array'); + expect(component.rows.length).toBe(0); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.ts new file mode 100644 index 00000000..55d79970 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-control.component.ts @@ -0,0 +1,112 @@ +import { CoreModule, LocalizationService } from '@abp/ng.core'; +import { CommonModule } from '@angular/common'; +import { AbstractControl, FormArray, FormGroup, ReactiveFormsModule, ValidatorFn } from '@angular/forms'; +import { Component, inject } from '@angular/core'; +import { FlexFieldControlComponent } from '../../components/flex-field-control.component'; +import type { FlexFieldValue } from '../../models'; +import { flexFieldErrorMessage } from '../../utils'; +import { FieldTypeControlBase } from '../field-type-control-base'; +import type { InlineFieldDefinition } from '../inline-field-definition'; +import { normalizeInlineFieldDefinitions } from '../inline-field-definition'; +import { normalizeTableRows } from './table-row'; +import { TableConfiguration } from './table-configuration'; + +/** + * Edits the value of a `Table` field: a `FormArray` of rows, each a `FormGroup` holding a `values` + * group that each column's own recursively-mounted `` populates one control at a + * time - the same mechanism `MatrixControlComponent` uses for its blocks, minus the block-type picker + * (there is only one column schema, so a single "add row" button is enough). + */ +@Component({ + selector: 'ff-table-control', + templateUrl: './table-control.component.html', + imports: [CoreModule, CommonModule, ReactiveFormsModule, FlexFieldControlComponent], +}) +export class TableControlComponent extends FieldTypeControlBase { + private readonly localization = inject(LocalizationService); + + private readonly rowValueSeeds = new WeakMap>(); + + /** `columnValueOf` is called from the template on every change-detection cycle, and + * `` only keeps its mounted child alive across a cycle if `[fields]` is + * reference-equal to what it rendered last time - a fresh object literal per call defeats that and + * tears down/rebuilds the recursively-mounted control (losing focus and in-progress input) on every + * cycle, not just when the column actually changes. `columns` is only replaced when `createControl()` + * reruns, so caching by column identity is safe here. */ + private readonly columnValueCache = new WeakMap(); + + columns: InlineFieldDefinition[] = []; + + get rows(): FormArray { + return (this.fieldControl as FormArray) ?? this.fb.array([]); + } + + protected configurationDefaults(): object { + return new TableConfiguration(); + } + + protected createControl(): AbstractControl { + this.columns = normalizeInlineFieldDefinitions( + this.fieldValue?.field.configuration['Table.Columns'], + ); + const stored = normalizeTableRows(this.selectedValue); + + const validators: ValidatorFn[] = []; + if (this.fieldValue!.required) { + validators.push(control => ((control as FormArray).length > 0 ? null : { required: true })); + } + + const array = this.fb.array([], validators); + stored.forEach(row => array.push(this.buildRowGroup(row.values))); + return array; + } + + valuesGroupOf(row: AbstractControl): FormGroup { + return (row as FormGroup).get('values') as FormGroup; + } + + columnValueOf(column: InlineFieldDefinition): FlexFieldValue { + let value = this.columnValueCache.get(column); + if (!value) { + value = { + field: { + id: '', + name: column.name, + // Blank, not `column.displayName`: the `` header already names this column, and every + // built-in control template only renders its own `form-label` when `displayName` is truthy - + // an empty string suppresses it without any control needing to know it is inside a table cell. + displayName: '', + description: column.description, + fieldTypeName: column.fieldTypeName, + configuration: column.configuration, + }, + required: column.required, + searchable: false, + }; + this.columnValueCache.set(column, value); + } + return value; + } + + selectedValueOf(row: AbstractControl, column: InlineFieldDefinition): unknown { + return this.rowValueSeeds.get(row as FormGroup)?.[column.name]; + } + + columnErrorMessage(row: AbstractControl, column: InlineFieldDefinition): string | null { + return flexFieldErrorMessage(this.valuesGroupOf(row).get(column.name), this.localization); + } + + addRow(): void { + this.rows.push(this.buildRowGroup({})); + } + + removeRow(index: number): void { + this.rows.removeAt(index); + } + + private buildRowGroup(values: Record): FormGroup { + const group = new FormGroup({ values: new FormGroup({}) }); + this.rowValueSeeds.set(group, values); + return group; + } +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-row.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-row.spec.ts new file mode 100644 index 00000000..b9348d8f --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-row.spec.ts @@ -0,0 +1,32 @@ +import { normalizeTableRows } from './table-row'; + +describe('normalizeTableRows', () => { + it('reads anything that is not an array as empty', () => { + expect(normalizeTableRows(undefined)).toEqual([]); + expect(normalizeTableRows(null)).toEqual([]); + expect(normalizeTableRows('')).toEqual([]); + expect(normalizeTableRows({ values: {} })).toEqual([]); + }); + + it('fills an empty values bag for a member with nothing in it', () => { + expect(normalizeTableRows([{}, null])).toEqual([{ values: {} }, { values: {} }]); + }); + + it('passes stored rows through, keeping only values — a row carries no type tag', () => { + expect(normalizeTableRows([{ values: { title: 'One', qty: 2 } }])).toEqual([ + { values: { title: 'One', qty: 2 } }, + ]); + }); + + it('reads a PascalCase row into the same camelCase result', () => { + expect(normalizeTableRows([{ Values: { title: 'One', qty: 2 } }])).toEqual([ + { values: { title: 'One', qty: 2 } }, + ]); + }); + + it('reads casings mixed across rows', () => { + expect( + normalizeTableRows([{ Values: { title: 'One' } }, { values: { title: 'Two' } }, {}]), + ).toEqual([{ values: { title: 'One' } }, { values: { title: 'Two' } }, { values: {} }]); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-row.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-row.ts new file mode 100644 index 00000000..9b28b4bc --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-row.ts @@ -0,0 +1,23 @@ +/** One row - what a `Table` field's value is a list of. Mirrors the server's `TableRow`. Unlike + * `MatrixBlockValue` there is no type-tag: every row shares the same `Table.Columns` schema. */ +export interface TableRow { + values: Record; +} + +/** A stored row in either casing - see `InlineFieldDefinition` for why both are read. */ +type RawTableRow = Partial<{ + values: Record; + Values: Record; +}>; + +/** Reads a stored Table field's value - a list of rows - defensively. */ +export function normalizeTableRows(source: unknown): TableRow[] { + if (!Array.isArray(source)) { + return []; + } + + return source.map((item: unknown) => { + const value = (item ?? {}) as RawTableRow; + return { values: value.values ?? value.Values ?? {} }; + }); +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.html b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.html new file mode 100644 index 00000000..82bf9738 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.html @@ -0,0 +1,30 @@ +@if (showInList) { + {{ rows.length === 0 ? '-' : rows.length + ' row(s)' }} +} @else { +
+ {{ fields.field.displayName }} + @if (rows.length === 0 || columns.length === 0) { + - + } @else { + + + + + + + + + + + +
{{ column.displayName }}
+ +
+ } +
+} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.spec.ts new file mode 100644 index 00000000..6b481810 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.spec.ts @@ -0,0 +1,92 @@ +import { TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@abp/ng.core/testing'; +import { FlexFieldValue } from '../../models'; +import { provideFlexFields } from '../../providers'; +import { TableViewComponent } from './table-view.component'; + +const COLUMNS = [ + { name: 'title', displayName: 'Title', fieldTypeName: 'Text', required: false, configuration: {} }, + { name: 'qty', displayName: 'Quantity', fieldTypeName: 'Number', required: false, configuration: {} }, +]; + +function fieldValue(overrides: Partial = {}): FlexFieldValue { + return { + field: { + id: '1', + name: 'specs', + displayName: 'Specs', + fieldTypeName: 'Table', + configuration: { 'Table.Columns': COLUMNS }, + }, + required: false, + searchable: false, + ...overrides, + }; +} + +function render(fields: FlexFieldValue, value: unknown, showInList = false) { + const fixture = TestBed.createComponent(TableViewComponent); + fixture.componentRef.setInput('fields', fields); + fixture.componentRef.setInput('type', 'Table'); + fixture.componentRef.setInput('value', value); + fixture.componentRef.setInput('showInList', showInList); + fixture.detectChanges(); + return fixture; +} + +describe('TableViewComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreTestingModule.withConfig()], + providers: [provideFlexFields()], + }); + }); + + it('renders one header per configured column', () => { + const fixture = render(fieldValue(), [{ values: { title: 'One', qty: 2 } }]); + + const headers = [...fixture.nativeElement.querySelectorAll('thead th')].map((th: HTMLElement) => + th.textContent!.trim(), + ); + expect(headers).toEqual(['Title', 'Quantity']); + }); + + it('recurses into for each cell', () => { + const fixture = render(fieldValue(), [{ values: { title: 'One', qty: 2 } }]); + + const cells = [...fixture.nativeElement.querySelectorAll('tbody td')].map((td: HTMLElement) => + td.textContent!.trim(), + ); + // Values only reach the DOM if each column's own registered view component rendered them. + expect(cells).toEqual(['One', '2']); + }); + + it('shows a dash when there are no rows, no columns, or nothing readable', () => { + expect(render(fieldValue(), []).nativeElement.textContent).toContain('-'); + expect(render(fieldValue(), undefined).nativeElement.textContent).toContain('-'); + expect( + render( + fieldValue({ + field: { + id: '1', + name: 'specs', + displayName: 'Specs', + fieldTypeName: 'Table', + configuration: {}, + }, + }), + [{ values: { title: 'One' } }], + ).nativeElement.textContent, + ).toContain('-'); + }); + + it('collapses to a row count in list mode', () => { + const fixture = render( + fieldValue(), + [{ values: { title: 'One', qty: 1 } }, { values: { title: 'Two', qty: 2 } }], + true, + ); + + expect(fixture.nativeElement.textContent.trim()).toBe('2 row(s)'); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.ts b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.ts new file mode 100644 index 00000000..7cd19a62 --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/field-types/table/table-view.component.ts @@ -0,0 +1,73 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnChanges } from '@angular/core'; +import { FlexFieldViewComponent } from '../../components/flex-field-view.component'; +import type { FlexFieldValue } from '../../models'; +import type { InlineFieldDefinition } from '../inline-field-definition'; +import { normalizeInlineFieldDefinitions } from '../inline-field-definition'; +import type { TableRow } from './table-row'; +import { normalizeTableRows } from './table-row'; + +/** + * Displays the value of a `Table` field read-only, as a literal table: one column per configured + * `InlineFieldDefinition`, each cell recursively rendered via `` in list mode - + * reusing the existing top-level dispatcher rather than hand-writing rendering per column field type. + */ +@Component({ + selector: 'ff-table-view', + templateUrl: './table-view.component.html', + imports: [CommonModule, FlexFieldViewComponent], +}) +export class TableViewComponent implements OnChanges { + @Input() showInList = false; + + @Input() fields?: FlexFieldValue; + + /** Registration key of the field type - always `Table` here. */ + @Input() type?: string; + + @Input() value: unknown = ''; + + rows: TableRow[] = []; + columns: InlineFieldDefinition[] = []; + + /** `columnValueOf` is called from the template on every change-detection cycle, and + * `` only keeps its mounted child alive across a cycle if `[fields]` is + * reference-equal to what it rendered last time - a fresh object literal per call defeats that and + * tears down/rebuilds every recursively-mounted view on every cycle, not just when the value actually + * changes. Keyed on `row` (outer) then column name (inner): `ngOnChanges` replaces `this.rows` + * wholesale on a real value change, so stale entries for old row objects simply stop being reachable + * rather than needing explicit invalidation. */ + private readonly columnValueCache = new WeakMap>(); + + ngOnChanges(): void { + this.rows = normalizeTableRows(this.value); + this.columns = normalizeInlineFieldDefinitions(this.fields?.field.configuration['Table.Columns']); + } + + columnValueOf(column: InlineFieldDefinition, row: TableRow): FlexFieldValue { + let rowCache = this.columnValueCache.get(row); + if (!rowCache) { + rowCache = new Map(); + this.columnValueCache.set(row, rowCache); + } + + let value = rowCache.get(column.name); + if (!value) { + value = { + field: { + id: '', + name: column.name, + displayName: column.displayName, + description: column.description, + fieldTypeName: column.fieldTypeName, + configuration: column.configuration, + }, + required: column.required, + searchable: false, + value: row.values[column.name], + }; + rowCache.set(column.name, value); + } + return value; + } +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/providers/provide-flex-fields.ts b/flex-fields/angular/projects/flex-fields/src/lib/providers/provide-flex-fields.ts index 15983c00..a15fa276 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/providers/provide-flex-fields.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/providers/provide-flex-fields.ts @@ -4,7 +4,7 @@ import { FieldTypeDefinition } from '../field-types/field-type-definition'; import { FLEX_FIELD_TYPES } from '../field-types/field-type.tokens'; /** - * Registers the six built-in field types, plus any extras you pass. Call once, in your application + * Registers the eight built-in field types, plus any extras you pass. Call once, in your application * config: * * ```ts diff --git a/flex-fields/angular/projects/flex-fields/src/lib/utils/flex-field-error-message.spec.ts b/flex-fields/angular/projects/flex-fields/src/lib/utils/flex-field-error-message.spec.ts new file mode 100644 index 00000000..5884e22b --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/utils/flex-field-error-message.spec.ts @@ -0,0 +1,62 @@ +import { LocalizationService } from '@abp/ng.core'; +import { FormControl, Validators } from '@angular/forms'; +import { flexFieldErrorMessage } from './flex-field-error-message'; + +/** + * A stub rather than the real service: what matters here is *which key* and *which bound* the mapping + * reaches for — both are wire values shared with the server's `FlexFields` localization resource — + * not what the resource happens to render them as. + */ +const localization = { + instant: (key: string, ...args: string[]) => [key, ...args].join('|'), +} as unknown as LocalizationService; + +describe('flexFieldErrorMessage', () => { + it('says nothing about a control the user has not reached yet', () => { + const control = new FormControl('', Validators.required); + expect(control.errors).toEqual({ required: true }); + expect(flexFieldErrorMessage(control, localization)).toBeNull(); + }); + + it('says nothing when there is no control at all', () => { + expect(flexFieldErrorMessage(null, localization)).toBeNull(); + expect(flexFieldErrorMessage(undefined, localization)).toBeNull(); + }); + + it('says nothing about a touched, valid control', () => { + const control = new FormControl('something', Validators.required); + control.markAsTouched(); + expect(flexFieldErrorMessage(control, localization)).toBeNull(); + }); + + it('maps required to the shared ABP key, not one of its own', () => { + const control = new FormControl('', Validators.required); + control.markAsTouched(); + expect(flexFieldErrorMessage(control, localization)).toBe('AbpValidation::ThisFieldIsRequired'); + }); + + it('maps min to FlexFields::Validate:MinValue, bound included', () => { + const control = new FormControl(1, Validators.min(5)); + control.markAsTouched(); + expect(flexFieldErrorMessage(control, localization)).toBe('FlexFields::Validate:MinValue|5'); + }); + + it('maps max to FlexFields::Validate:MaxValue, bound included', () => { + const control = new FormControl(9, Validators.max(5)); + control.markAsTouched(); + expect(flexFieldErrorMessage(control, localization)).toBe('FlexFields::Validate:MaxValue|5'); + }); + + it('maps maxlength to FlexFields::Validate:MaxLength, required length included', () => { + const control = new FormControl('abcdef', Validators.maxLength(3)); + control.markAsTouched(); + expect(flexFieldErrorMessage(control, localization)).toBe('FlexFields::Validate:MaxLength|3'); + }); + + it('reports required first when a control carries more than one error', () => { + const control = new FormControl(1); + control.setErrors({ required: true, min: { min: 5, actual: 1 } }); + control.markAsTouched(); + expect(flexFieldErrorMessage(control, localization)).toBe('AbpValidation::ThisFieldIsRequired'); + }); +}); diff --git a/flex-fields/angular/projects/flex-fields/src/lib/utils/flex-field-error-message.ts b/flex-fields/angular/projects/flex-fields/src/lib/utils/flex-field-error-message.ts new file mode 100644 index 00000000..845c5ced --- /dev/null +++ b/flex-fields/angular/projects/flex-fields/src/lib/utils/flex-field-error-message.ts @@ -0,0 +1,36 @@ +import { AbstractControl } from '@angular/forms'; +import { LocalizationService } from '@abp/ng.core'; + +/** + * Maps a mounted flex field's own control errors to the same localized text, regardless of which + * field type produced them or how deep the control is nested (top-level, a Table column, a Matrix + * sub-field): every built-in field type only ever raises `required`/`min`/`max`/`maxlength`, since + * that's the full set `NumberControlComponent`/`TextControlComponent`/etc. push. + * Gated on `touched` so a fresh, still-empty required field doesn't show red before the user reaches it. + */ +export function flexFieldErrorMessage( + control: AbstractControl | null | undefined, + localization: LocalizationService, +): string | null { + const errors = control?.touched ? control.errors : null; + if (!errors) { + return null; + } + + if (errors['required']) { + return localization.instant('AbpValidation::ThisFieldIsRequired'); + } + if (errors['min']) { + return localization.instant('FlexFields::Validate:MinValue', String(errors['min'].min)); + } + if (errors['max']) { + return localization.instant('FlexFields::Validate:MaxValue', String(errors['max'].max)); + } + if (errors['maxlength']) { + return localization.instant( + 'FlexFields::Validate:MaxLength', + String(errors['maxlength'].requiredLength), + ); + } + return null; +} diff --git a/flex-fields/angular/projects/flex-fields/src/lib/utils/index.ts b/flex-fields/angular/projects/flex-fields/src/lib/utils/index.ts index 3f6dc1b3..67dc695e 100644 --- a/flex-fields/angular/projects/flex-fields/src/lib/utils/index.ts +++ b/flex-fields/angular/projects/flex-fields/src/lib/utils/index.ts @@ -1,2 +1,3 @@ +export * from './flex-field-error-message'; export * from './read-string-list'; export * from './slug-generator'; diff --git a/flex-fields/angular/src/app/app.config.ts b/flex-fields/angular/src/app/app.config.ts index 26ec15a0..fd9f69b3 100644 --- a/flex-fields/angular/src/app/app.config.ts +++ b/flex-fields/angular/src/app/app.config.ts @@ -41,7 +41,7 @@ export const appConfig: ApplicationConfig = { provideThemeLeptonX(), provideSideMenuLayout(), provideLogo(withEnvironmentOptions(environment)), - // Registers the six built-in field types' FieldTypeResolver - without this, + // Registers the eight built-in field types' FieldTypeResolver - without this, // renders nothing, because the registry it looks the field type up in is empty. provideFlexFields(), // Bolt-on: the FileExplorer field type, demonstrating a field type registered from outside diff --git a/flex-fields/angular/src/app/product-fields/product-fields.component.html b/flex-fields/angular/src/app/product-fields/product-fields.component.html index 983514c5..5b64420b 100644 --- a/flex-fields/angular/src/app/product-fields/product-fields.component.html +++ b/flex-fields/angular/src/app/product-fields/product-fields.component.html @@ -68,7 +68,7 @@
{{ 'Demo::Menu:ProductFields' | abpLocalization }}
- +

{{ (editingField ? 'AbpUi::Edit' : 'Demo::NewField') | abpLocalization }}

diff --git a/flex-fields/angular/src/app/products/products.component.html b/flex-fields/angular/src/app/products/products.component.html index 90fbf9e6..a03b7801 100644 --- a/flex-fields/angular/src/app/products/products.component.html +++ b/flex-fields/angular/src/app/products/products.component.html @@ -5,7 +5,7 @@
{{ 'Demo::Search' | abpLocalization }}
@for (field of searchableFields; track field.id) {
- f.searchable); } @@ -216,7 +216,9 @@ export class ProductsComponent { * - `Select` / `Tree`: multi-select, so `In` over the comma-joined selection. * - `Boolean`: the native `