@blueprint-ts/core 2.0.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 +10 -0
- package/docs/vue/forms.md +179 -28
- 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/tests/service/requests/FormDataBody.test.ts +63 -0
- package/tests/vue/forms/BaseForm.transformers.test.ts +109 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
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
|
|
1
11
|
## v2.0.0 - 2026-02-18
|
|
2
12
|
|
|
3
13
|
# [2.0.0](/compare/v1.2.0...v2.0.0) (2026-02-18)
|
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
|
+
````
|
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
|
|
|
@@ -115,7 +115,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
115
115
|
private readonly original: FormBody
|
|
116
116
|
private readonly _model: { [K in keyof FormBody]: ComputedRef<FormBody[K]> }
|
|
117
117
|
private _errors: any = reactive({})
|
|
118
|
-
private _suggestions: any = reactive({})
|
|
119
118
|
private _hasErrors: ComputedRef<boolean>
|
|
120
119
|
protected append: string[] = []
|
|
121
120
|
protected ignore: string[] = []
|
|
@@ -673,15 +672,13 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
673
672
|
}
|
|
674
673
|
}
|
|
675
674
|
|
|
676
|
-
public fillSuggestions(suggestionsData: Partial<Record<keyof FormBody, string[] | object[]>>): void {
|
|
677
|
-
for (const key in suggestionsData) {
|
|
678
|
-
if (Object.prototype.hasOwnProperty.call(suggestionsData, key)) {
|
|
679
|
-
this._suggestions[key] = suggestionsData[key]
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
675
|
private transformValue(value: any, parentKey?: string): any {
|
|
676
|
+
if (value instanceof Date) {
|
|
677
|
+
return value
|
|
678
|
+
}
|
|
679
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) {
|
|
680
|
+
return value
|
|
681
|
+
}
|
|
685
682
|
if (value instanceof PropertyAwareArray) {
|
|
686
683
|
return [...value].map((item) => this.transformValue(item, parentKey))
|
|
687
684
|
}
|
|
@@ -694,12 +691,18 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
694
691
|
if (parentKey) {
|
|
695
692
|
const compositeMethod = 'get' + upperFirst(parentKey) + upperFirst(camelCase(prop))
|
|
696
693
|
if (typeof (this as any)[compositeMethod] === 'function') {
|
|
697
|
-
|
|
694
|
+
const transformed = (this as any)[compositeMethod](value[prop])
|
|
695
|
+
if (transformed !== undefined) {
|
|
696
|
+
result[prop] = transformed
|
|
697
|
+
}
|
|
698
698
|
continue
|
|
699
699
|
}
|
|
700
700
|
}
|
|
701
701
|
// Pass the parentKey along so that nested objects still use it.
|
|
702
|
-
|
|
702
|
+
const transformed = this.transformValue(value[prop], parentKey)
|
|
703
|
+
if (transformed !== undefined) {
|
|
704
|
+
result[prop] = transformed
|
|
705
|
+
}
|
|
703
706
|
}
|
|
704
707
|
return result
|
|
705
708
|
}
|
|
@@ -718,9 +721,15 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
718
721
|
const getterName = 'get' + upperFirst(camelCase(key))
|
|
719
722
|
const typedKey = key as unknown as keyof RequestBody
|
|
720
723
|
if (typeof (this as any)[getterName] === 'function') {
|
|
721
|
-
|
|
724
|
+
const transformed = (this as any)[getterName](value)
|
|
725
|
+
if (transformed !== undefined) {
|
|
726
|
+
payload[typedKey] = transformed
|
|
727
|
+
}
|
|
722
728
|
} else {
|
|
723
|
-
|
|
729
|
+
const transformed = this.transformValue(value, key)
|
|
730
|
+
if (transformed !== undefined) {
|
|
731
|
+
payload[typedKey] = transformed
|
|
732
|
+
}
|
|
724
733
|
}
|
|
725
734
|
}
|
|
726
735
|
|
|
@@ -732,7 +741,10 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
732
741
|
|
|
733
742
|
const getterName = 'get' + upperFirst(camelCase(fieldName))
|
|
734
743
|
if (typeof (this as any)[getterName] === 'function') {
|
|
735
|
-
|
|
744
|
+
const transformed = (this as any)[getterName]()
|
|
745
|
+
if (transformed !== undefined) {
|
|
746
|
+
payload[fieldName as keyof RequestBody] = transformed
|
|
747
|
+
}
|
|
736
748
|
} else {
|
|
737
749
|
console.warn(`Getter method '${getterName}' not found for appended field '${fieldName}' in ${this.constructor.name}.`)
|
|
738
750
|
}
|
|
@@ -766,9 +778,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
766
778
|
for (const key in this._errors) {
|
|
767
779
|
delete this._errors[key]
|
|
768
780
|
}
|
|
769
|
-
for (const key in this._suggestions) {
|
|
770
|
-
delete this._suggestions[key]
|
|
771
|
-
}
|
|
772
781
|
driver.set(this.constructor.name, {
|
|
773
782
|
state: toRaw(this.state),
|
|
774
783
|
original: toRaw(this.original),
|
|
@@ -881,7 +890,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
881
890
|
}
|
|
882
891
|
}),
|
|
883
892
|
errors: (this._errors[key] && this._errors[key][index] && this._errors[key][index][innerKey]) || [],
|
|
884
|
-
suggestions: (this._suggestions[key] && this._suggestions[key][index] && this._suggestions[key][index][innerKey]) || [],
|
|
885
893
|
dirty:
|
|
886
894
|
Array.isArray(this.dirty[key]) && this.dirty[key][index] && typeof (this.dirty[key] as any[])[index] === 'object'
|
|
887
895
|
? (this.dirty[key] as any[])[index][innerKey]
|
|
@@ -906,7 +914,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
906
914
|
}
|
|
907
915
|
}),
|
|
908
916
|
errors: (this._errors[key] && this._errors[key][index]) || [],
|
|
909
|
-
suggestions: (this._suggestions[key] && this._suggestions[key][index]) || [],
|
|
910
917
|
dirty: Array.isArray(this.dirty[key]) ? (this.dirty[key] as boolean[])[index] : false,
|
|
911
918
|
touched: this.touched[key] || false
|
|
912
919
|
}
|
|
@@ -917,7 +924,6 @@ export abstract class BaseForm<RequestBody extends object, FormBody extends obje
|
|
|
917
924
|
props[key] = {
|
|
918
925
|
model: this._model[key],
|
|
919
926
|
errors: this._errors[key] || [],
|
|
920
|
-
suggestions: this._suggestions[key] || [],
|
|
921
927
|
dirty: this.dirty[key] || false,
|
|
922
928
|
touched: this.touched[key] || false
|
|
923
929
|
}
|
|
@@ -6,7 +6,6 @@ import { type WritableComputedRef } from 'vue'
|
|
|
6
6
|
export interface PropertyAwareField<T> {
|
|
7
7
|
model: WritableComputedRef<T>
|
|
8
8
|
errors: any[]
|
|
9
|
-
suggestions: any[]
|
|
10
9
|
dirty: boolean
|
|
11
10
|
}
|
|
12
11
|
|
|
@@ -21,7 +20,7 @@ export type PropertyAware<T> = {
|
|
|
21
20
|
* Extends Array with property awareness.
|
|
22
21
|
* When a form field is defined as an instance of PropertyAwareArray,
|
|
23
22
|
* the BaseForm will transform each element into reactive properties with
|
|
24
|
-
* computed getters/setters, error
|
|
23
|
+
* computed getters/setters, error tracking, and dirty flags.
|
|
25
24
|
*/
|
|
26
25
|
export class PropertyAwareArray<T = any> extends Array<T> {
|
|
27
26
|
/**
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { FormDataBody } from '../../../src/service/requests/bodies/FormDataBody'
|
|
3
|
+
|
|
4
|
+
describe('FormDataBody', () => {
|
|
5
|
+
it('appends strings and files', () => {
|
|
6
|
+
const file = new File(['abc'], 'credentials.kdbx', { type: 'application/octet-stream' })
|
|
7
|
+
|
|
8
|
+
const body = new FormDataBody({
|
|
9
|
+
name: 'aerg',
|
|
10
|
+
type: 'kdbx',
|
|
11
|
+
password: 'aerg',
|
|
12
|
+
file,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const fd = body.getContent()
|
|
16
|
+
|
|
17
|
+
expect(fd.get('name')).toBe('aerg')
|
|
18
|
+
expect(fd.get('type')).toBe('kdbx')
|
|
19
|
+
expect(fd.get('password')).toBe('aerg')
|
|
20
|
+
expect(fd.get('file')).toBe(file)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('encodes null as empty string (key present)', () => {
|
|
24
|
+
const body = new FormDataBody({
|
|
25
|
+
name: 'aerg',
|
|
26
|
+
file: null,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const fd = body.getContent()
|
|
30
|
+
expect(fd.get('name')).toBe('aerg')
|
|
31
|
+
expect(fd.get('file')).toBe('')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('rejects undefined values (should not be silently dropped)', () => {
|
|
35
|
+
expect(
|
|
36
|
+
() =>
|
|
37
|
+
new FormDataBody({
|
|
38
|
+
missing: undefined,
|
|
39
|
+
} as any),
|
|
40
|
+
).toThrow()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('stringifies number/boolean values', () => {
|
|
44
|
+
const body = new FormDataBody({
|
|
45
|
+
count: 3,
|
|
46
|
+
enabled: false,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const fd = body.getContent()
|
|
50
|
+
expect(fd.get('count')).toBe('3')
|
|
51
|
+
expect(fd.get('enabled')).toBe('false')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('supports arrays via bracket notation', () => {
|
|
55
|
+
const body = new FormDataBody({
|
|
56
|
+
tags: ['a', 'b'],
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const fd = body.getContent()
|
|
60
|
+
expect(fd.get('tags[0]')).toBe('a')
|
|
61
|
+
expect(fd.get('tags[1]')).toBe('b')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { BaseForm } from '../../../src/vue/forms/BaseForm'
|
|
3
|
+
|
|
4
|
+
type PositionsItem = { id: number; internal: string }
|
|
5
|
+
|
|
6
|
+
interface TestFormState {
|
|
7
|
+
name: string
|
|
8
|
+
email: string | null
|
|
9
|
+
meta: { id: string; secret: string }
|
|
10
|
+
positions: PositionsItem[]
|
|
11
|
+
file: File | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type TestRequestPayload = {
|
|
15
|
+
name?: string
|
|
16
|
+
email: string | null
|
|
17
|
+
meta: { id: string }
|
|
18
|
+
positions: Array<{ id: number }>
|
|
19
|
+
file?: File
|
|
20
|
+
started_at?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class TestForm extends BaseForm<TestRequestPayload, TestFormState> {
|
|
24
|
+
protected override append: string[] = ['started_at']
|
|
25
|
+
|
|
26
|
+
public constructor(overrides: Partial<TestFormState> = {}) {
|
|
27
|
+
super({
|
|
28
|
+
name: '',
|
|
29
|
+
email: null,
|
|
30
|
+
meta: { id: 'm1', secret: 'top-secret' },
|
|
31
|
+
positions: [
|
|
32
|
+
{ id: 1, internal: 'x' },
|
|
33
|
+
{ id: 2, internal: 'y' },
|
|
34
|
+
],
|
|
35
|
+
file: null,
|
|
36
|
+
...overrides,
|
|
37
|
+
}, { persist: false })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Field getter: omit name if empty by returning undefined.
|
|
41
|
+
protected getName(value: string): string | undefined {
|
|
42
|
+
const trimmed = value.trim()
|
|
43
|
+
return trimmed.length ? trimmed : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Composite getter: omit nested meta.secret by returning undefined.
|
|
47
|
+
protected getMetaSecret(_value: string): undefined {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Composite getter: omit positions[].internal by returning undefined.
|
|
52
|
+
protected getPositionsInternal(_value: string): undefined {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Appended getter: omit started_at by returning undefined.
|
|
57
|
+
protected getStartedAt(): undefined {
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('BaseForm transformers / getters', () => {
|
|
63
|
+
it('omits top-level fields when a field getter returns undefined', () => {
|
|
64
|
+
const form = new TestForm({ name: ' ' })
|
|
65
|
+
const payload = form.buildPayload()
|
|
66
|
+
|
|
67
|
+
expect(Object.prototype.hasOwnProperty.call(payload, 'name')).toBe(false)
|
|
68
|
+
expect(payload).toMatchObject({
|
|
69
|
+
email: null,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('keeps null values (only undefined omits)', () => {
|
|
74
|
+
const form = new TestForm({ name: 'Alice', email: null })
|
|
75
|
+
const payload = form.buildPayload()
|
|
76
|
+
|
|
77
|
+
expect(payload).toHaveProperty('name', 'Alice')
|
|
78
|
+
expect(payload).toHaveProperty('email', null)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('omits nested properties when a composite getter returns undefined', () => {
|
|
82
|
+
const form = new TestForm({ name: 'Alice' })
|
|
83
|
+
const payload = form.buildPayload()
|
|
84
|
+
|
|
85
|
+
expect(payload.meta).toEqual({ id: 'm1' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('applies composite omission to arrays of objects', () => {
|
|
89
|
+
const form = new TestForm({ name: 'Alice' })
|
|
90
|
+
const payload = form.buildPayload()
|
|
91
|
+
|
|
92
|
+
expect(payload.positions).toEqual([{ id: 1 }, { id: 2 }])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('omits appended fields when their getter returns undefined', () => {
|
|
96
|
+
const form = new TestForm({ name: 'Alice' })
|
|
97
|
+
const payload = form.buildPayload()
|
|
98
|
+
|
|
99
|
+
expect(Object.prototype.hasOwnProperty.call(payload, 'started_at')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('keeps File values intact (does not transform into a plain object)', () => {
|
|
103
|
+
const file = new File(['abc'], 'credentials.kdbx', { type: 'application/octet-stream' })
|
|
104
|
+
const form = new TestForm({ name: 'Alice', file })
|
|
105
|
+
const payload = form.buildPayload()
|
|
106
|
+
|
|
107
|
+
expect(payload.file).toBe(file)
|
|
108
|
+
})
|
|
109
|
+
})
|