@blueprint-ts/core 1.2.0 → 3.0.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/CHANGELOG.md +18 -0
- package/docs/vue/forms.md +179 -28
- package/docs/vue/requests/route-resource-binding.md +193 -25
- package/package.json +1 -1
- package/src/service/requests/bodies/FormDataBody.ts +45 -9
- package/src/vue/forms/BaseForm.ts +26 -20
- package/src/vue/forms/PropertyAwareArray.ts +1 -2
- package/src/vue/router/routeResourceBinding/RouteResourceBoundView.ts +145 -0
- package/src/vue/router/routeResourceBinding/defineRoute.ts +29 -1
- package/src/vue/router/routeResourceBinding/index.ts +2 -1
- package/src/vue/router/routeResourceBinding/installRouteInjection.ts +86 -15
- package/src/vue/router/routeResourceBinding/types.ts +6 -1
- package/src/vue/router/routeResourceBinding/useRouteResource.ts +17 -7
- package/tests/service/requests/FormDataBody.test.ts +63 -0
- package/tests/vue/forms/BaseForm.transformers.test.ts +109 -0
- package/tests/vue/router/routeResourceBinding/RouteResourceBoundView.test.ts +344 -0
- package/tests/vue/router/routeResourceBinding/installRouteInjection.test.ts +450 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## v3.0.0 - 2026-02-19
|
|
2
|
+
|
|
3
|
+
# [3.0.0](/compare/v2.0.0...v3.0.0) (2026-02-19)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* Omit BaseForm fields when transformer returns undefined 26c0d0e
|
|
9
|
+
* Removed suggestions feature from BaseForm.ts f21d6ce
|
|
10
|
+
* Support file uploads in BaseForm and improve form data handling in requests d765cae
|
|
11
|
+
## v2.0.0 - 2026-02-18
|
|
12
|
+
|
|
13
|
+
# [2.0.0](/compare/v1.2.0...v2.0.0) (2026-02-18)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* Cache resolved route resources for child routes db70b77
|
|
1
19
|
## v1.2.0 - 2026-01-25
|
|
2
20
|
|
|
3
21
|
# [1.2.0](/compare/v1.1.2...v1.2.0) (2026-01-25)
|
package/docs/vue/forms.md
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
applications. It provides a comprehensive solution for managing form data with features like:
|
|
7
7
|
|
|
8
8
|
- Type-safe form state management
|
|
9
|
-
- Dirty state tracking for
|
|
9
|
+
- Dirty + touched state tracking for fields
|
|
10
10
|
- Error handling and field validation
|
|
11
11
|
- Form persistence between page reloads
|
|
12
12
|
- Support for complex nested objects and arrays
|
|
13
13
|
- Automatic transformation of form values to API payloads
|
|
14
|
+
- Supports `File`/`Blob` fields for multipart requests (see “Files / Uploads”)
|
|
14
15
|
|
|
15
16
|
## Key Features
|
|
16
17
|
|
|
@@ -33,19 +34,85 @@ Forms can automatically save their state to browser storage (session, local, etc
|
|
|
33
34
|
return without losing their input.
|
|
34
35
|
|
|
35
36
|
````typescript
|
|
36
|
-
protected override getPersistenceDriver(): PersistenceDriver
|
|
37
|
-
|
|
38
|
-
return new SessionStorageDriver() // Or LocalStorageDriver, etc.
|
|
37
|
+
protected override getPersistenceDriver(suffix?: string): PersistenceDriver {
|
|
38
|
+
return new SessionStorageDriver(suffix) // Or LocalStorageDriver(suffix), etc.
|
|
39
39
|
}
|
|
40
40
|
````
|
|
41
41
|
|
|
42
|
+
Notes:
|
|
43
|
+
- Persistence is enabled by default. Disable it via `super(defaults, { persist: false })`.
|
|
44
|
+
- `persistSuffix` is passed into `getPersistenceDriver(suffix)` and is typically used to namespace the storage key.
|
|
45
|
+
- Persisted state is only reused if the stored `original` matches your current `defaults`; otherwise it is discarded.
|
|
46
|
+
- `persist: false` disables the automatic rehydration + background persistence, but some explicit mutation helpers (e.g. `fillState()`, `reset()`, `addToArrayProperty()`) still call the driver.
|
|
47
|
+
- `File`/`Blob` values are not JSON-serializable, so persistence is not supported for file inputs. Use `{ persist: false }` for file upload forms.
|
|
48
|
+
|
|
42
49
|
### 3. Transformations and Getters
|
|
43
50
|
|
|
44
|
-
|
|
51
|
+
`buildPayload()` supports three “getter” patterns to transform values before sending them to your API.
|
|
52
|
+
|
|
53
|
+
Special value types:
|
|
54
|
+
- `Date` values are treated as scalars and preserved (not “object-walked”). When sent as JSON they serialize to ISO strings via `JSON.stringify()`, and when sent as multipart `FormData` they are appended as `toISOString()`.
|
|
55
|
+
- `File`/`Blob` values are also treated as scalars and preserved for multipart uploads.
|
|
56
|
+
|
|
57
|
+
#### A) Field Getter (common)
|
|
58
|
+
If your form `state` contains a field, you can define a getter for that same field name. During `buildPayload()`,
|
|
59
|
+
`BaseForm` will call it with the field’s current value and use the return value in the payload.
|
|
60
|
+
|
|
61
|
+
Getter name format: `get${upperFirst(camelCase(fieldName))}(value)`
|
|
62
|
+
|
|
63
|
+
Omitting fields:
|
|
64
|
+
- If a field getter returns `undefined`, the field is **not added** to the payload object.
|
|
65
|
+
- Other values (`null`, `false`, `0`, `''`, empty arrays/objects) are included as-is.
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
|
|
69
|
+
````typescript
|
|
70
|
+
// state.name -> payload.name (trimmed)
|
|
71
|
+
protected getName(value: string): string {
|
|
72
|
+
return value.trim()
|
|
73
|
+
}
|
|
74
|
+
````
|
|
75
|
+
|
|
76
|
+
This works for arrays too (plain arrays and `PropertyAwareArray`):
|
|
77
|
+
|
|
78
|
+
````typescript
|
|
79
|
+
// state.positions -> payload.positions (mapped)
|
|
80
|
+
protected getPositions(positions: PositionItem[]): Array<{ id: number }> {
|
|
81
|
+
return positions.map((p) => ({ id: p.id }))
|
|
82
|
+
}
|
|
83
|
+
````
|
|
84
|
+
|
|
85
|
+
#### B) Composite Getter for Nested Props (automatic fallback)
|
|
86
|
+
If you do *not* provide a field getter for a given top-level field, `BaseForm` will recursively walk objects/arrays and
|
|
87
|
+
allow transforming nested properties via composite getter names.
|
|
88
|
+
|
|
89
|
+
Composite getter format: `get${upperFirst(parentFieldKey)}${upperFirst(camelCase(propName))}(value)`
|
|
90
|
+
|
|
91
|
+
Example (field key `businessAssociate`, prop `id`):
|
|
45
92
|
|
|
46
93
|
````typescript
|
|
94
|
+
// state.businessAssociate.id -> payload.businessAssociate.id (replaced with the resource id)
|
|
95
|
+
protected getBusinessAssociateId(value: BusinessAssociateResource | null): string | null {
|
|
96
|
+
return value?.id ?? null
|
|
97
|
+
}
|
|
98
|
+
````
|
|
99
|
+
|
|
100
|
+
Notes:
|
|
101
|
+
- This applies to arrays of objects too, because arrays are mapped recursively.
|
|
102
|
+
- The “parent field” part uses the original field key with only the first character uppercased (not camel-cased).
|
|
103
|
+
- Returning `undefined` from a composite getter omits that nested property from the payload object.
|
|
104
|
+
|
|
105
|
+
#### C) Appended / Computed Payload Fields (advanced)
|
|
106
|
+
If you need payload fields that do not exist in `state`, add their names to `append`. `buildPayload()` will then call a
|
|
107
|
+
zero-argument getter for each appended field.
|
|
108
|
+
|
|
109
|
+
Example (append key `started_at` → getter `getStartedAt()`):
|
|
110
|
+
|
|
111
|
+
````typescript
|
|
112
|
+
protected override append: string[] = ['started_at']
|
|
113
|
+
|
|
47
114
|
protected getStartedAt(): string {
|
|
48
|
-
|
|
115
|
+
return DateTime.fromFormat(`${this.state.start_date} ${this.state.start_time}`, 'dd.MM.yyyy HH:mm').toISO()
|
|
49
116
|
}
|
|
50
117
|
````
|
|
51
118
|
|
|
@@ -60,6 +127,16 @@ protected override errorMap: { [serverKey: string]: string | string[] } = {
|
|
|
60
127
|
}
|
|
61
128
|
````
|
|
62
129
|
|
|
130
|
+
Validation is configured by overriding `defineRules()` and returning per-field rules and an optional validation mode:
|
|
131
|
+
|
|
132
|
+
- `ValidationMode.DEFAULT` (default): validates on dirty, touch, and submit
|
|
133
|
+
- `ValidationMode.PASSIVE`: only validates on submit
|
|
134
|
+
- `ValidationMode.AGGRESSIVE`: validates immediately and on all triggers
|
|
135
|
+
- `ValidationMode.ON_DEPENDENT_CHANGE`: revalidates when a dependency changes (see below)
|
|
136
|
+
|
|
137
|
+
Rules can declare dependencies via `rule.dependsOn = ['otherField']`. Some rules (e.g. `ConfirmedRule`) implement
|
|
138
|
+
bidirectional dependencies, so changing either field revalidates the other.
|
|
139
|
+
|
|
63
140
|
### 5. Array Management
|
|
64
141
|
|
|
65
142
|
Special support for arrays with the class `PropertyAwareArray`, enabling reactive updates to array items:
|
|
@@ -85,6 +162,18 @@ fields that have been changed.
|
|
|
85
162
|
````typescript
|
|
86
163
|
// Check if any field in the form has been modified
|
|
87
164
|
form.isDirty()
|
|
165
|
+
|
|
166
|
+
// Check if a specific field has been modified
|
|
167
|
+
form.isDirty('email')
|
|
168
|
+
````
|
|
169
|
+
|
|
170
|
+
### Touched Tracking
|
|
171
|
+
|
|
172
|
+
Touched indicates user interaction (or programmatic updates via setters/fill methods).
|
|
173
|
+
|
|
174
|
+
````typescript
|
|
175
|
+
form.touch('email')
|
|
176
|
+
form.isTouched('email')
|
|
88
177
|
````
|
|
89
178
|
|
|
90
179
|
### The Properties Object
|
|
@@ -100,6 +189,11 @@ The `properties` getter provides access to each form field with its model, error
|
|
|
100
189
|
</template>
|
|
101
190
|
````
|
|
102
191
|
|
|
192
|
+
`properties.<field>` exposes:
|
|
193
|
+
- `model` (a `ComputedRef` compatible with `v-model`)
|
|
194
|
+
- `errors` (array; empty until validated/filled)
|
|
195
|
+
- `dirty` and `touched`
|
|
196
|
+
|
|
103
197
|
### Form Submission
|
|
104
198
|
|
|
105
199
|
Build a payload for API submission with:
|
|
@@ -108,12 +202,21 @@ Build a payload for API submission with:
|
|
|
108
202
|
const payload = form.buildPayload()
|
|
109
203
|
````
|
|
110
204
|
|
|
205
|
+
For validation on submit, call:
|
|
206
|
+
|
|
207
|
+
````typescript
|
|
208
|
+
const ok = form.validate(true)
|
|
209
|
+
if (!ok) return
|
|
210
|
+
await api.submitForm(form.buildPayload())
|
|
211
|
+
````
|
|
212
|
+
|
|
111
213
|
## How to Use
|
|
112
214
|
|
|
113
215
|
### 1. Create a Form Class
|
|
114
216
|
|
|
115
217
|
````typescript
|
|
116
218
|
import { BaseForm, type PersistenceDriver, SessionStorageDriver } from '@hank-it/ui/vue/forms'
|
|
219
|
+
import { RequiredRule, ValidationMode } from '@hank-it/ui/vue/forms/validation'
|
|
117
220
|
|
|
118
221
|
interface MyFormState {
|
|
119
222
|
name: string
|
|
@@ -144,8 +247,18 @@ class MyForm extends BaseForm<MyRequestPayload, MyFormState> {
|
|
|
144
247
|
}
|
|
145
248
|
|
|
146
249
|
// Use session storage for persistence
|
|
147
|
-
protected override getPersistenceDriver(): PersistenceDriver {
|
|
148
|
-
return new SessionStorageDriver()
|
|
250
|
+
protected override getPersistenceDriver(suffix?: string): PersistenceDriver {
|
|
251
|
+
return new SessionStorageDriver(suffix)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
protected override defineRules() {
|
|
255
|
+
return {
|
|
256
|
+
name: { rules: [new RequiredRule<MyFormState>('Name is required')] },
|
|
257
|
+
email: {
|
|
258
|
+
rules: [new RequiredRule<MyFormState>('Email is required')],
|
|
259
|
+
options: { mode: ValidationMode.DEFAULT }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
149
262
|
}
|
|
150
263
|
|
|
151
264
|
// Generate a timestamp for the request
|
|
@@ -184,12 +297,10 @@ class MyForm extends BaseForm<MyRequestPayload, MyFormState> {
|
|
|
184
297
|
<script setup>
|
|
185
298
|
import { MyForm } from './MyForm'
|
|
186
299
|
|
|
187
|
-
const form = new MyForm(
|
|
188
|
-
name: '',
|
|
189
|
-
email: ''
|
|
190
|
-
})
|
|
300
|
+
const form = new MyForm()
|
|
191
301
|
|
|
192
302
|
async function submitForm() {
|
|
303
|
+
if (!form.validate(true)) return
|
|
193
304
|
try {
|
|
194
305
|
const payload = form.buildPayload()
|
|
195
306
|
await api.submitForm(payload)
|
|
@@ -234,19 +345,8 @@ export class MyComplexForm extends BaseForm<RequestType, FormWithPositions> {
|
|
|
234
345
|
|
|
235
346
|
// Remove a position by id
|
|
236
347
|
public removePosition(id: number): void {
|
|
237
|
-
this.
|
|
238
|
-
|
|
239
|
-
)
|
|
240
|
-
this.resetPositionIds()
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Reset the sequential IDs after removing items
|
|
244
|
-
protected resetPositionIds(): void {
|
|
245
|
-
let count = 1
|
|
246
|
-
this.state.positions.forEach(position => {
|
|
247
|
-
position.id = count
|
|
248
|
-
count++
|
|
249
|
-
})
|
|
348
|
+
this.removeArrayItem('positions', (position) => position.id !== id)
|
|
349
|
+
this.resetArrayCounter('positions', 'id')
|
|
250
350
|
}
|
|
251
351
|
}
|
|
252
352
|
|
|
@@ -273,10 +373,13 @@ try {
|
|
|
273
373
|
}
|
|
274
374
|
````
|
|
275
375
|
|
|
276
|
-
|
|
376
|
+
`fillErrors` supports:
|
|
377
|
+
- direct field keys (e.g. `email`)
|
|
378
|
+
- array dot notation where the 2nd segment is a numeric index (e.g. `positions.0.value`)
|
|
379
|
+
- remapping via `errorMap` (including mapping one server key to multiple fields)
|
|
277
380
|
|
|
278
381
|
### 3. Filling Form State
|
|
279
|
-
Update multiple form fields at once
|
|
382
|
+
Update multiple form fields at once and recompute dirty/touched accordingly:
|
|
280
383
|
|
|
281
384
|
````typescript
|
|
282
385
|
form.fillState({
|
|
@@ -292,9 +395,50 @@ Update both the current and original state, keeping the field "clean":
|
|
|
292
395
|
form.syncValue('email', 'new@example.com')
|
|
293
396
|
````
|
|
294
397
|
|
|
398
|
+
### 5. Converting `properties` Back To Data
|
|
399
|
+
If you ever need a plain object from the `properties` tree (e.g. for debugging or integrating with non-`BaseForm` code),
|
|
400
|
+
use `propertyAwareToRaw`:
|
|
401
|
+
|
|
402
|
+
````typescript
|
|
403
|
+
import { propertyAwareToRaw } from '@hank-it/ui/vue/forms'
|
|
404
|
+
|
|
405
|
+
const raw = propertyAwareToRaw<MyFormState>(form.properties)
|
|
406
|
+
````
|
|
407
|
+
|
|
408
|
+
### 6. Checking For Errors
|
|
409
|
+
|
|
410
|
+
````typescript
|
|
411
|
+
form.hasErrors()
|
|
412
|
+
````
|
|
413
|
+
|
|
414
|
+
## Files / Uploads (Multipart)
|
|
415
|
+
If your form includes a file, keep it in state as `File | null` and disable persistence:
|
|
416
|
+
|
|
417
|
+
````typescript
|
|
418
|
+
interface UploadFormBody {
|
|
419
|
+
name: string
|
|
420
|
+
file: File | null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
class UploadForm extends BaseForm<RequestBody, UploadFormBody> {
|
|
424
|
+
constructor() {
|
|
425
|
+
super({ name: '', file: null }, { persist: false })
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
````
|
|
429
|
+
|
|
430
|
+
`buildPayload()` keeps `File`/`Blob` values intact, so you can send the payload using a multipart `FormData` request body
|
|
431
|
+
(e.g. the request layer’s `FormDataFactory` / `FormDataBody`).
|
|
432
|
+
|
|
433
|
+
If `file` is `null`, `FormDataBody` encodes it as an empty string (the key stays present). Many backends (e.g. Laravel with
|
|
434
|
+
`ConvertEmptyStringsToNull`) will treat that as `null` again.
|
|
435
|
+
|
|
295
436
|
## Real-World Examples
|
|
296
437
|
### 1. Date/Time Handling
|
|
297
438
|
|
|
439
|
+
This example shows the “appended/computed fields” pattern: `started_at` and `ended_at` are not part of the form state,
|
|
440
|
+
so they are listed in `append`, and `buildPayload()` calls `getStartedAt()` / `getEndedAt()` (no arguments).
|
|
441
|
+
|
|
298
442
|
````typescript
|
|
299
443
|
export class TimeTrackingEntryCreateUpdateForm extends BaseForm<RequestPayload, FormState> {
|
|
300
444
|
protected override append: string[] = ['started_at', 'ended_at']
|
|
@@ -312,6 +456,13 @@ export class TimeTrackingEntryCreateUpdateForm extends BaseForm<RequestPayload,
|
|
|
312
456
|
|
|
313
457
|
### 2. Complex Object Handling
|
|
314
458
|
|
|
459
|
+
If your form state contains nested objects, `buildPayload()` can transform individual nested properties via
|
|
460
|
+
composite getter names of the form `get<ParentField><NestedProp>()`, where:
|
|
461
|
+
- `ParentField` is based on the form field key (first character uppercased, not camel-cased)
|
|
462
|
+
- `NestedProp` is `upperFirst(camelCase(prop))`
|
|
463
|
+
|
|
464
|
+
Example (field key `businessAssociate`, prop `id`): `getBusinessAssociateId(...)`.
|
|
465
|
+
|
|
315
466
|
````typescript
|
|
316
467
|
export class IncomingVoucherCreateUpdateForm extends BaseForm<RequestPayload, FormState> {
|
|
317
468
|
// Extract IDs from related objects
|
|
@@ -323,4 +474,4 @@ export class IncomingVoucherCreateUpdateForm extends BaseForm<RequestPayload, Fo
|
|
|
323
474
|
return value?.id
|
|
324
475
|
}
|
|
325
476
|
}
|
|
326
|
-
````
|
|
477
|
+
````
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Route
|
|
1
|
+
# Route Resource Binding
|
|
2
2
|
|
|
3
3
|
When using `vue-router`, you can automatically bind route parameters to resources, similar to how Laravel's route model binding works.
|
|
4
4
|
|
|
@@ -7,60 +7,228 @@ When using `vue-router`, you can automatically bind route parameters to resource
|
|
|
7
7
|
To enable the router to load resources automatically, install the route injection plugin when initializing your router:
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
+
import { installRouteInjection } from '@blueprint-ts/core'
|
|
11
|
+
|
|
10
12
|
installRouteInjection(router)
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## Defining Routes
|
|
14
16
|
|
|
15
17
|
Use the `defineRoute` helper to define your routes and specify which parameters should be resolved into resources:
|
|
16
18
|
|
|
17
19
|
```ts
|
|
18
|
-
defineRoute
|
|
20
|
+
import { defineRoute, RouteResourceRequestResolver } from '@blueprint-ts/core'
|
|
21
|
+
import ProductDetailPage from '@/pages/ProductDetailPage.vue'
|
|
22
|
+
|
|
23
|
+
export default defineRoute<{
|
|
19
24
|
product: ProductResource
|
|
20
25
|
}>()({
|
|
21
26
|
path: ':productId',
|
|
22
27
|
name: 'products.show',
|
|
23
28
|
component: ProductDetailPage,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
)
|
|
32
|
-
}
|
|
29
|
+
inject: {
|
|
30
|
+
product: {
|
|
31
|
+
from: 'productId',
|
|
32
|
+
resolve: (productId: string) => {
|
|
33
|
+
return new RouteResourceRequestResolver(
|
|
34
|
+
new ProductShowRequest(productId)
|
|
35
|
+
)
|
|
33
36
|
}
|
|
34
37
|
}
|
|
35
38
|
}
|
|
36
39
|
})
|
|
37
40
|
```
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
Navigation is **non-blocking** — the route navigates immediately while resources resolve in the background. Cached values are reused when navigating between child routes with unchanged parameters.
|
|
40
43
|
|
|
41
44
|
## Usage in Components
|
|
42
45
|
|
|
43
|
-
Your component can
|
|
46
|
+
Your component can directly access the resolved resource via props:
|
|
47
|
+
|
|
48
|
+
```vue
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
const props = defineProps<{
|
|
51
|
+
product: ProductResource
|
|
52
|
+
}>()
|
|
53
|
+
</script>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Handling Loading & Error States
|
|
57
|
+
|
|
58
|
+
### Using `RouteResourceBoundView`
|
|
59
|
+
|
|
60
|
+
`RouteResourceBoundView` is a drop-in replacement for `<RouterView>` that automatically handles loading and error states. Define error and loading components directly in the route:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { defineRoute, RouteResourceRequestResolver } from '@blueprint-ts/core'
|
|
64
|
+
import ProductDetailPage from '@/pages/ProductDetailPage.vue'
|
|
65
|
+
import GenericErrorPage from '@/pages/GenericErrorPage.vue'
|
|
66
|
+
import LoadingSpinner from '@/components/LoadingSpinner.vue'
|
|
67
|
+
|
|
68
|
+
export default defineRoute<{
|
|
69
|
+
product: ProductResource
|
|
70
|
+
}>()({
|
|
71
|
+
path: ':productId',
|
|
72
|
+
name: 'products.show',
|
|
73
|
+
component: ProductDetailPage,
|
|
74
|
+
errorComponent: GenericErrorPage,
|
|
75
|
+
loadingComponent: LoadingSpinner,
|
|
76
|
+
inject: {
|
|
77
|
+
product: {
|
|
78
|
+
from: 'productId',
|
|
79
|
+
resolve: (productId: string) => {
|
|
80
|
+
return new RouteResourceRequestResolver(
|
|
81
|
+
new ProductShowRequest(productId)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then replace `<RouterView>` with `<RouteResourceBoundView>` in your layout:
|
|
90
|
+
|
|
91
|
+
```vue
|
|
92
|
+
<template>
|
|
93
|
+
<RouteResourceBoundView />
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<script setup lang="ts">
|
|
97
|
+
import { RouteResourceBoundView } from '@blueprint-ts/core'
|
|
98
|
+
</script>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The `errorComponent` receives `error` and `refresh` as props. The `refresh` function retries all failed resources:
|
|
102
|
+
|
|
103
|
+
```vue
|
|
104
|
+
<!-- GenericErrorPage.vue -->
|
|
105
|
+
<template>
|
|
106
|
+
<div>
|
|
107
|
+
<p>{{ error.message }}</p>
|
|
108
|
+
<button @click="refresh">Try Again</button>
|
|
109
|
+
</div>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<script setup lang="ts">
|
|
113
|
+
defineProps<{
|
|
114
|
+
error: Error
|
|
115
|
+
refresh: () => Promise<void>
|
|
116
|
+
}>()
|
|
117
|
+
</script>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### Using Scoped Slots
|
|
121
|
+
|
|
122
|
+
`RouteResourceBoundView` supports the same `v-slot` pattern as `<RouterView>`:
|
|
123
|
+
|
|
124
|
+
```vue
|
|
125
|
+
<RouteResourceBoundView v-slot="{ Component, route }">
|
|
126
|
+
<component
|
|
127
|
+
:is="Component"
|
|
128
|
+
v-if="Component"
|
|
129
|
+
/>
|
|
130
|
+
<EmptyState v-else />
|
|
131
|
+
</RouteResourceBoundView>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
When no `errorComponent` or `loadingComponent` is defined on the route, you can use named slots as fallbacks:
|
|
44
135
|
|
|
45
136
|
```vue
|
|
137
|
+
<RouteResourceBoundView>
|
|
138
|
+
<template #default="{ Component, route }">
|
|
139
|
+
<component :is="Component" v-if="Component" />
|
|
140
|
+
</template>
|
|
141
|
+
|
|
142
|
+
<template #loading>
|
|
143
|
+
<LoadingSpinner />
|
|
144
|
+
</template>
|
|
145
|
+
|
|
146
|
+
<template #error="{ error, refresh }">
|
|
147
|
+
<div>
|
|
148
|
+
<p>{{ error.message }}</p>
|
|
149
|
+
<button @click="refresh">Retry</button>
|
|
150
|
+
</div>
|
|
151
|
+
</template>
|
|
152
|
+
</RouteResourceBoundView>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Using `useRouteResource` (Manual Handling)
|
|
156
|
+
|
|
157
|
+
If you prefer to handle loading and error states inside the component itself, set `lazy: false` on the route. This renders the component immediately while resources resolve in the background:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
export default defineRoute<{
|
|
161
|
+
product: ProductResource
|
|
162
|
+
}>()({
|
|
163
|
+
path: ':productId',
|
|
164
|
+
name: 'products.show',
|
|
165
|
+
component: ProductDetailPage,
|
|
166
|
+
lazy: false,
|
|
167
|
+
inject: {
|
|
168
|
+
product: {
|
|
169
|
+
from: 'productId',
|
|
170
|
+
resolve: (productId: string) => {
|
|
171
|
+
return new RouteResourceRequestResolver(
|
|
172
|
+
new ProductShowRequest(productId)
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Then use the `useRouteResource` composable inside your component:
|
|
181
|
+
|
|
182
|
+
```vue
|
|
183
|
+
<template>
|
|
184
|
+
<div v-if="isLoading">Loading...</div>
|
|
185
|
+
<div v-else-if="error">
|
|
186
|
+
<p>{{ error.message }}</p>
|
|
187
|
+
<button @click="refresh">Retry</button>
|
|
188
|
+
</div>
|
|
189
|
+
<div v-else>
|
|
190
|
+
<h1>{{ product.name }}</h1>
|
|
191
|
+
</div>
|
|
192
|
+
</template>
|
|
193
|
+
|
|
46
194
|
<script setup lang="ts">
|
|
195
|
+
import { useRouteResource } from '@blueprint-ts/core'
|
|
196
|
+
|
|
47
197
|
const props = defineProps<{
|
|
48
198
|
product: ProductResource
|
|
49
199
|
}>()
|
|
200
|
+
|
|
201
|
+
const { refresh, isLoading, error } = useRouteResource('product')
|
|
50
202
|
</script>
|
|
51
203
|
```
|
|
52
204
|
|
|
53
|
-
|
|
205
|
+
`useRouteResource` returns:
|
|
206
|
+
|
|
207
|
+
| Property | Type | Description |
|
|
208
|
+
|-------------|---------------------------|------------------------------------------|
|
|
209
|
+
| `isLoading` | `ComputedRef<boolean>` | `true` while the resource is resolving |
|
|
210
|
+
| `error` | `ComputedRef<Error\|null>` | The error if resolution failed, else `null` |
|
|
211
|
+
| `refresh` | `(options?: { silent?: boolean }) => Promise<void>` | Re-fetches the resource. Pass `{ silent: true }` to suppress the loading state. |
|
|
212
|
+
|
|
213
|
+
#### Silent Refresh
|
|
54
214
|
|
|
55
|
-
|
|
215
|
+
By default, calling `refresh()` sets `isLoading` to `true` while the resource is being re-fetched, which causes `RouteResourceBoundView` to show the loading component. If you want to refresh the resource in the background without triggering the loading state (e.g. polling or optimistic updates), pass `{ silent: true }`:
|
|
56
216
|
|
|
57
217
|
```ts
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
218
|
+
const { refresh } = useRouteResource('product')
|
|
219
|
+
|
|
220
|
+
// Normal refresh — triggers loading state
|
|
221
|
+
await refresh()
|
|
222
|
+
|
|
223
|
+
// Silent refresh — does not trigger loading state
|
|
224
|
+
await refresh({ silent: true })
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A silent refresh still updates the `error` state if the request fails.
|
|
228
|
+
|
|
229
|
+
### Lazy vs Non-Lazy
|
|
230
|
+
|
|
231
|
+
| Option | Behavior |
|
|
232
|
+
|-----------------|-----------------------------------------------------------------------------------------------|
|
|
233
|
+
| `lazy: true` (default) | `RouteResourceBoundView` intercepts loading/error states and shows the appropriate component |
|
|
234
|
+
| `lazy: false` | The target component renders immediately; use `useRouteResource()` for manual state handling |
|
package/package.json
CHANGED
|
@@ -22,17 +22,53 @@ export class FormDataBody<RequestBody> implements BodyContract {
|
|
|
22
22
|
if (Object.prototype.hasOwnProperty.call(data, property)) {
|
|
23
23
|
const formKey = namespace ? namespace + '[' + property + ']' : property
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
const value = (data as any)[property]
|
|
26
|
+
|
|
27
|
+
// Null is a valid "explicitly empty" value in many APIs.
|
|
28
|
+
// In multipart FormData we encode it as an empty string so the key is still present.
|
|
29
|
+
if (value === null) {
|
|
30
|
+
form.append(formKey, '')
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Undefined values should not reach the request layer (BaseForm omits them).
|
|
35
|
+
// Reject explicitly to avoid silently dropping keys.
|
|
36
|
+
if (value === undefined) {
|
|
34
37
|
throw new Error('Unexpected value')
|
|
35
38
|
}
|
|
39
|
+
|
|
40
|
+
if (value instanceof Date) {
|
|
41
|
+
form.append(formKey, value.toISOString())
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Support arrays via bracket notation: key[0], key[1], ...
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
for (let i = 0; i < value.length; i++) {
|
|
48
|
+
this.toFormData({ [String(i)]: value[i] } as any, form, formKey)
|
|
49
|
+
}
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Files/Blobs should be appended directly (File extends Blob)
|
|
54
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) {
|
|
55
|
+
form.append(formKey, value)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// if the property is an object, use recursivity.
|
|
60
|
+
if (isObject(value)) {
|
|
61
|
+
this.toFormData(value as any, form, formKey)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Primitives: append as strings
|
|
66
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
67
|
+
form.append(formKey, String(value))
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error('Unexpected value')
|
|
36
72
|
}
|
|
37
73
|
}
|
|
38
74
|
|