spiderly 19.10.1 → 19.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/docs/angular-customization/index.md +2 -0
- package/agent/docs/entity-design/index.md +18 -2
- package/agent/docs/entity-design/references/attributes.generated.md +0 -1
- package/agent/docs/mapper-customization/index.md +49 -27
- package/fesm2022/spiderly.mjs +26 -13
- package/fesm2022/spiderly.mjs.map +1 -1
- package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +0 -2
- package/lib/services/app-layout.service.base.d.ts +6 -0
- package/lib/services/web-storage.d.ts +4 -0
- package/package.json +1 -1
|
@@ -363,6 +363,8 @@ menu: SpiderlyMenuItem[] = [
|
|
|
363
363
|
</spiderly-layout>
|
|
364
364
|
```
|
|
365
365
|
|
|
366
|
+
The side menu's desktop collapse state persists automatically (localStorage key `spiderly-layout:menu-desktop-inactive`), so the sidebar stays collapsed/expanded across reloads.
|
|
367
|
+
|
|
366
368
|
## Validation
|
|
367
369
|
|
|
368
370
|
### ValidatorAbstractService
|
|
@@ -25,6 +25,23 @@ namespace Foo.Business.Entities
|
|
|
25
25
|
|
|
26
26
|
Hand-written DTOs use `[SpiderlyDTO]`. Generated DTOs (`{Entity}DTO`, `{Entity}SaveBodyDTO`, `{Entity}MainUIFormDTO`) need no marker. The `spiderly add-new-entity` CLI emits `[SpiderlyEntity]` automatically.
|
|
27
27
|
|
|
28
|
+
### Generated DTO nullability
|
|
29
|
+
|
|
30
|
+
`[Required]` decides it for **reference types only**:
|
|
31
|
+
|
|
32
|
+
| Entity property | Generated DTO property |
|
|
33
|
+
| ------------------------- | --------------------------------------------------------- |
|
|
34
|
+
| `[Required] string Name` | `string Name { get; set; } = null!;` |
|
|
35
|
+
| `string Slug` | `string? Slug` (bare `string` for a nullable-oblivious app) |
|
|
36
|
+
| `[Required] int Quantity` | `int? Quantity` — value types stay nullable, see below |
|
|
37
|
+
| `int Stock` | `int? Stock` |
|
|
38
|
+
| any navigation | `long? CategoryId` + `string? CategoryDisplayName` |
|
|
39
|
+
| any collection | unchanged, with `= new()` |
|
|
40
|
+
|
|
41
|
+
**Value types stay nullable even under `[Required]`, deliberately.** A generated `{Entity}DTO` serves four roles at once: response shape, request shape, the Angular form model (an empty numeric input posts `null`), and the sparse placeholder carrier for `[ComplexManyToManyList]` grids, where an all-null row *is* the "no record" sentinel. The last two need `null` to be representable, and `int` cannot hold it. Reference types are safe to tighten because, with `RespectNullableAnnotations` off, a non-nullable `string` still accepts `null` at runtime — `= null!` is an assertion for the compiler and for humans, not a runtime constraint.
|
|
42
|
+
|
|
43
|
+
**The C# annotation is invisible to the OpenAPI spec unless the app opts in** with `spiderly.AddSwagger(options => options.SupportNonNullableReferenceTypes())`. Not on by default: it narrows the published contract (members become `required`), which regenerates every consumer's typed client — schedule it as its own deploy. `spiderly init` scaffolds it, since a new app has no consumers yet. `AddSwagger`'s callback runs after Spiderly's defaults and is the seam for any other Swashbuckle option too.
|
|
44
|
+
|
|
28
45
|
## Base Classes
|
|
29
46
|
|
|
30
47
|
| Base Class | Use When | Generated |
|
|
@@ -67,7 +84,7 @@ public virtual Brand Brand { get; set; }
|
|
|
67
84
|
### When to use it
|
|
68
85
|
|
|
69
86
|
- **Hand-written save/sync code** that builds the entity directly (`new Order { BrandId = id, ... }`) — skips the `FindAsync` + navigation-attach roundtrip that the naive pattern requires
|
|
70
|
-
- **Hot read paths**
|
|
87
|
+
- **Hot read paths** in mapper projections — the mapper emits `x.BrandId` instead of the `EF.Property<long>(x, "BrandId")` workaround for [EF Core #15826](https://github.com/dotnet/efcore/issues/15826), which otherwise still forces a JOIN in some queries
|
|
71
88
|
|
|
72
89
|
### Rules
|
|
73
90
|
|
|
@@ -298,7 +315,6 @@ The complete list of every Spiderly attribute and its valid target is generated
|
|
|
298
315
|
| `[ExcludeServiceMethodsFromGeneration]` | Property | Skip generated service methods (implement custom logic) |
|
|
299
316
|
| `[GreaterThanOrEqualTo(N)]` | Property | Numeric minimum validation |
|
|
300
317
|
| `[Email]` | Property | Email format validation |
|
|
301
|
-
| `[ProjectToDTO(".Map(...)")]` | Class | Custom Mapster projection |
|
|
302
318
|
| `[GenerateCommaSeparatedDisplayName]` | Property | Add comma-separated display names to DTO |
|
|
303
319
|
| `[ComplexManyToManyList]` | Property | Editable list UI for complex M2M junction (small sets only) |
|
|
304
320
|
| `[ComplexManyToManyReadonlyTable]` | Property | Read-only table for complex M2M junction |
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
| `[M2MWithMany]` | Property | Marks a property in a many-to-many (M2M) relationship. |
|
|
28
28
|
| `[MaxFileSize]` | Property | Specifies the maximum allowed file size (in bytes) for a blob property. When not applied, the file upload defaults to 20 MB (20,000,000 bytes). Use this attribute alongside any StorageAttribute subclass for file uploads. |
|
|
29
29
|
| `[Output]` | All | Specifies the output configuration for the Source Generator. Note: This is a temporary solution and may be replaced in future versions. |
|
|
30
|
-
| `[ProjectToDTO]` | Class | Specifies custom mapping configuration when projecting an entity to its DTO. |
|
|
31
30
|
| `[ReadOnly]` | Property | Marks a property as server-owned: the client may read it, but can never write it (mirrors OpenAPI readOnly). The property stays in the read {Entity}DTO — so it's still returned by GET endpoints and shown in the list table — but the generator closes the write path for it: no inbound FluentValidation rule is emitted, and the generated DTO→entity Mapster config .Ignore()s it, so a crafted payload can't tamper with it. It also gets no editable control in the generated details form. Use for values only backend code assigns — usage counters, denormalized aggregates, computed timestamps. Combine with [Required] to document the non-null DB column without generating an unsatisfiable inbound rule (the form never sends it). This differs from [UIDoNotGenerate] (hidden from the form but still writable and validated) and [ExcludeFromDTO] (dropped from every DTO, unreadable). |
|
|
32
31
|
| `[S3PrivateStorage]` | Property | Routes upload/delete operations for the decorated string property through S3PrivateStorageService. The column stores an opaque S3 key; access is expected to be mediated via signed URLs or a backend proxy rather than direct CDN retrieval. Intended for files that contain personal or compliance-sensitive data (warranty receipts, ID documents, customer-uploaded invoices). |
|
|
33
32
|
| `[S3PublicStorage]` | Property | Routes upload/delete operations for the decorated string property through S3PublicStorageService. The bucket is configured for public access and the column stores a fully-qualified CDN URL; objects are uploaded with Cache-Control: public, max-age=31536000, immutable. |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mapper-customization
|
|
3
|
-
description: Customize Spiderly-generated Mapster mappers. Use when overriding DTO-to-entity or entity-to-DTO mapping, adding computed fields to DTOs, customizing query projections, or
|
|
3
|
+
description: Customize Spiderly-generated Mapster mappers. Use when overriding DTO-to-entity or entity-to-DTO mapping, adding computed fields to DTOs, customizing query projections, or implementing Customize* partial mapper hooks.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Mapper Customization
|
|
@@ -35,48 +35,71 @@ public static TypeAdapterConfig CartToDTOConfig()
|
|
|
35
35
|
}
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
## `
|
|
38
|
+
## `Customize*` Partial Hooks — Add Mappings in Real C#
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
Every generated config method declares a matching `static partial void Customize{MethodName}(TypeAdapterConfig config)`
|
|
41
|
+
hook and calls it before returning. Implement it in your hand-written `Mapper` partial class to
|
|
42
|
+
**add** custom mappings on top of the generated ones — compiler-checked, IntelliSense-assisted,
|
|
43
|
+
and able to express null guards (which a projection through an optional navigation requires):
|
|
41
44
|
|
|
42
45
|
```csharp
|
|
43
|
-
[
|
|
44
|
-
public
|
|
46
|
+
[SpiderlyDataMapper]
|
|
47
|
+
public static partial class Mapper
|
|
45
48
|
{
|
|
46
|
-
|
|
49
|
+
static partial void CustomizeOrderItemProjectToConfig(TypeAdapterConfig config)
|
|
50
|
+
{
|
|
51
|
+
// ProductVariant is an optional nav (nullable FK) — the guard is mandatory:
|
|
52
|
+
// an unguarded src.ProductVariant.ProductId LEFT JOINs to NULL and crashes the
|
|
53
|
+
// EF shaper with "Nullable object must have a value" at materialization.
|
|
54
|
+
config.ForType<OrderItem, OrderItemDTO>()
|
|
55
|
+
.Map(dest => dest.ProductId, src => src.ProductVariant != null ? (int?)src.ProductVariant.ProductId : null);
|
|
56
|
+
}
|
|
47
57
|
}
|
|
48
58
|
```
|
|
49
59
|
|
|
50
|
-
|
|
60
|
+
Use `config.ForType<...>()` (get-or-extend), never `NewConfig` (replace) — the generated
|
|
61
|
+
M2O/display-name mappings are already on the config when the hook runs. An unimplemented hook
|
|
62
|
+
compiles away entirely; implementing a hook whose config method you also fully overrode (see below)
|
|
63
|
+
is a compile error (`CS0759`), so a dead hook can't sit around silently.
|
|
51
64
|
|
|
52
|
-
|
|
65
|
+
**Precedence (verified against Mapster 7.4.0):** `.Map` is *first-registration-wins* — a hook
|
|
66
|
+
`.Map` targeting a member the generator already mapped is a **silent no-op**, not an override.
|
|
67
|
+
`.Ignore` *does* win over an earlier `.Map`. So from a hook you can **add** new member mappings and
|
|
68
|
+
**suppress** generated ones, but you cannot **replace** a generated mapping's expression — that is
|
|
69
|
+
exactly what the full-method override (below) remains for.
|
|
53
70
|
|
|
54
|
-
|
|
55
|
-
is not a property on the DTO, the field **never appears in the generated Angular type**
|
|
56
|
-
(`entities.generated.ts`) — `[ProjectToDTO]` fills a value that has nowhere to land. (A common
|
|
57
|
-
false alarm: the value maps fine at runtime, so it looks like the frontend "didn't regenerate" —
|
|
58
|
-
but a normal `dotnet build` *does* regenerate the Angular files; the property was just never on
|
|
59
|
-
the DTO for the generator to emit.)
|
|
71
|
+
### Convention flattening is OFF — unmapped extension props stay unmapped
|
|
60
72
|
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
Generated configs strip Mapster's flatten-by-name strategy (`NewStrictConfig()` in the generated
|
|
74
|
+
mapper). Without this, a DTO extension prop named e.g. `ShippingTierIsBulky` would *silently*
|
|
75
|
+
project through `src.ShippingTier.IsBulky` — and when that navigation is optional, the LEFT JOIN's
|
|
76
|
+
NULL crashes the shaper on a non-nullable member. With flattening off, a prop you never mapped
|
|
77
|
+
simply stays at its default; wire it deliberately via a `Customize*` hook.
|
|
78
|
+
|
|
79
|
+
### The hook only fills the value — the field must exist on the DTO
|
|
80
|
+
|
|
81
|
+
A mapping does **not** create the property. If `dest.ProductId` is not a property on the DTO, the
|
|
82
|
+
field **never appears in the generated Angular type** (`entities.generated.ts`). Declare the
|
|
83
|
+
property on a `partial class {Entity}DTO` extension — it merges into the generated DTO
|
|
84
|
+
automatically, no attribute needed — **then** map its value in the hook:
|
|
63
85
|
|
|
64
86
|
```csharp
|
|
65
|
-
//
|
|
66
|
-
//
|
|
87
|
+
// The property — a partial extension of the generated OrderItemDTO. No [SpiderlyDTO]
|
|
88
|
+
// needed: a partial that extends a generated DTO is merged in by name.
|
|
67
89
|
public partial class OrderItemDTO
|
|
68
90
|
{
|
|
69
91
|
public int? ProductId { get; set; }
|
|
70
92
|
}
|
|
71
|
-
|
|
72
|
-
// 2. The value — [ProjectToDTO] on the entity fills it during projection.
|
|
73
|
-
[ProjectToDTO(".Map(dest => dest.ProductId, src => src.ProductVariant.ProductId)")]
|
|
74
|
-
public class OrderItem : BusinessObject<long> { /* ... */ }
|
|
75
93
|
```
|
|
76
94
|
|
|
77
95
|
Then `dotnet build` the backend — the source generators run on build and the field appears
|
|
78
96
|
in `entities.generated.ts`. There is no separate "regenerate" command; the build is it.
|
|
79
97
|
|
|
98
|
+
> **Removed:** the string-based `[ProjectToDTO(".Map(...)")]` entity attribute. Its string DSL
|
|
99
|
+
> could not express a null guard (the parser choked on anything beyond a bare dotted path), which
|
|
100
|
+
> made unsafe projections through optional navs the *only* writable form. Move any existing usage
|
|
101
|
+
> into the corresponding `Customize{Entity}ProjectToConfig` hook.
|
|
102
|
+
|
|
80
103
|
## Partial Method Override — Full Control
|
|
81
104
|
|
|
82
105
|
For complex mapping logic, override the entire generated method. The generator **skips generation** for any method that already exists in the user's partial `Mapper` class (detected by method name match).
|
|
@@ -124,10 +147,9 @@ public static partial class Mapper
|
|
|
124
147
|
|
|
125
148
|
| Scenario | Approach |
|
|
126
149
|
|---|---|
|
|
127
|
-
| Add
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
| Change
|
|
131
|
-
| Change Excel export projection | Override `{Entity}ExcelProjectToConfig()` |
|
|
150
|
+
| Add computed/custom fields to any config (projection, DTO, save, Excel) | Implement the matching `Customize{Entity}{Method}` partial hook |
|
|
151
|
+
| Mapping through an optional navigation | `Customize*` hook with an explicit null guard |
|
|
152
|
+
| Suppress a generated mapping | `Customize*` hook with `.Ignore(dest => dest.X)` |
|
|
153
|
+
| Change or replace generated mappings | Override the full method in `Mapper.cs` |
|
|
132
154
|
|
|
133
155
|
Most projects never need custom mappers — the generated mappings handle M2O, M2M display names, and standard field-to-field mapping automatically.
|
package/fesm2022/spiderly.mjs
CHANGED
|
@@ -3151,7 +3151,27 @@ class InitTopBarData extends BaseEntity {
|
|
|
3151
3151
|
static { this.typeName = 'InitTopBarData'; }
|
|
3152
3152
|
}
|
|
3153
3153
|
|
|
3154
|
+
/** Parses a JSON entry from web storage; missing or corrupted entries read as null. */
|
|
3155
|
+
function readStoredJson(storage, key) {
|
|
3156
|
+
try {
|
|
3157
|
+
return JSON.parse(storage.getItem(key) ?? 'null');
|
|
3158
|
+
}
|
|
3159
|
+
catch {
|
|
3160
|
+
return null;
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
/** Writes a value to web storage as JSON; the counterpart of readStoredJson. */
|
|
3164
|
+
function writeStoredJson(storage, key, value) {
|
|
3165
|
+
storage.setItem(key, JSON.stringify(value));
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3154
3168
|
class LayoutServiceBase {
|
|
3169
|
+
/**
|
|
3170
|
+
* Only the desktop collapse persists across reloads (a durable preference,
|
|
3171
|
+
* like the data table's column layout); the rest of LayoutState is
|
|
3172
|
+
* deliberately transient.
|
|
3173
|
+
*/
|
|
3174
|
+
static { this.menuDesktopInactiveKey = 'spiderly-layout:menu-desktop-inactive'; }
|
|
3155
3175
|
constructor(apiService, config, authService) {
|
|
3156
3176
|
this.apiService = apiService;
|
|
3157
3177
|
this.config = config;
|
|
@@ -3163,7 +3183,8 @@ class LayoutServiceBase {
|
|
|
3163
3183
|
colorScheme: 'light',
|
|
3164
3184
|
};
|
|
3165
3185
|
this.state = {
|
|
3166
|
-
staticMenuDesktopInactive:
|
|
3186
|
+
staticMenuDesktopInactive: readStoredJson(localStorage, LayoutServiceBase.menuDesktopInactiveKey) ===
|
|
3187
|
+
true,
|
|
3167
3188
|
overlayMenuActive: false,
|
|
3168
3189
|
profileSidebarVisible: false,
|
|
3169
3190
|
profileDropdownSidebarVisible: false,
|
|
@@ -3200,6 +3221,7 @@ class LayoutServiceBase {
|
|
|
3200
3221
|
if (this.isDesktop()) {
|
|
3201
3222
|
this.state.staticMenuDesktopInactive =
|
|
3202
3223
|
!this.state.staticMenuDesktopInactive;
|
|
3224
|
+
writeStoredJson(localStorage, LayoutServiceBase.menuDesktopInactiveKey, this.state.staticMenuDesktopInactive);
|
|
3203
3225
|
}
|
|
3204
3226
|
else {
|
|
3205
3227
|
this.state.staticMenuMobileActive = !this.state.staticMenuMobileActive;
|
|
@@ -4179,23 +4201,14 @@ class SpiderlyDataTableComponent {
|
|
|
4179
4201
|
localStorage.removeItem(this.columnsStateKey);
|
|
4180
4202
|
}
|
|
4181
4203
|
else {
|
|
4182
|
-
localStorage
|
|
4204
|
+
writeStoredJson(localStorage, this.columnsStateKey, this.columnVisibilityOverrides);
|
|
4183
4205
|
}
|
|
4184
4206
|
}
|
|
4185
4207
|
restoreColumnVisibility() {
|
|
4186
4208
|
if (!this.columnsStateKey)
|
|
4187
4209
|
return;
|
|
4188
4210
|
this.columnVisibilityOverrides =
|
|
4189
|
-
|
|
4190
|
-
}
|
|
4191
|
-
/** Parses a JSON entry from web storage; missing or corrupted entries read as null. */
|
|
4192
|
-
static readStoredJson(storage, key) {
|
|
4193
|
-
try {
|
|
4194
|
-
return JSON.parse(storage.getItem(key) ?? 'null');
|
|
4195
|
-
}
|
|
4196
|
-
catch {
|
|
4197
|
-
return null;
|
|
4198
|
-
}
|
|
4211
|
+
readStoredJson(localStorage, this.columnsStateKey) ?? {};
|
|
4199
4212
|
}
|
|
4200
4213
|
/**
|
|
4201
4214
|
* PrimeNG's persisted table state (filters/sort/pagination), read directly from storage
|
|
@@ -4204,7 +4217,7 @@ class SpiderlyDataTableComponent {
|
|
|
4204
4217
|
persistedTableState() {
|
|
4205
4218
|
if (!this.resolvedStateKey)
|
|
4206
4219
|
return null;
|
|
4207
|
-
return
|
|
4220
|
+
return readStoredJson(this.stateStorage === 'local' ? localStorage : sessionStorage, this.resolvedStateKey);
|
|
4208
4221
|
}
|
|
4209
4222
|
/**
|
|
4210
4223
|
* Guards the "hidden contributes nothing" invariant against state this component didn't
|