@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 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 individual fields
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
- Transform form values before they are sent to the server using getter methods:
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
- return DateTime.fromFormat(`${this.state.start_date} ${this.state.start_time}`, 'dd.MM.yyyy HH:mm').toISO()
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.state.positions = new PropertyAwareArray(
238
- this.state.positions.filter(position => position.id !== id)
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
- The `fileErrors` method currently only supports the Laravel style dot notation errors.
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, preserving the dirty state:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueprint-ts/core",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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
- // if the property is an object, but not a File, use recursivity.
26
- if (data[property] instanceof Date) {
27
- form.append(formKey, data[property].toISOString())
28
- } else if (isObject(data[property]) && !(data[property] instanceof File)) {
29
- // @ts-expect-error Problem with property of object
30
- this.toFormData(data[property], form, formKey)
31
- } else if (data[property] instanceof File || typeof data[property] === 'string') {
32
- form.append(formKey, data[property])
33
- } else {
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
- result[prop] = (this as any)[compositeMethod](value[prop])
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
- result[prop] = this.transformValue(value[prop], parentKey)
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
- payload[typedKey] = (this as any)[getterName](value)
724
+ const transformed = (this as any)[getterName](value)
725
+ if (transformed !== undefined) {
726
+ payload[typedKey] = transformed
727
+ }
722
728
  } else {
723
- payload[typedKey] = this.transformValue(value, key)
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
- payload[fieldName as keyof RequestBody] = (this as any)[getterName]()
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/suggestion tracking, and dirty flags.
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
+ })