@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 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 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
+ ````
@@ -1,4 +1,4 @@
1
- # Route Model Binding
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
- ### Defining Routes
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
- meta: {
25
- inject: {
26
- product: {
27
- from: 'productId',
28
- resolve: (productId: string) => {
29
- return new RouteModelRequestResolver(
30
- new ProductShowRequest(productId)
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
- The `beforeResolve` navigation guard will automatically fetch the `ProductResource` using the `ProductShowRequest` and the `productId` from the route.
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 then directly access the resolved resource via props:
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
- ## Handling Loading States
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
- You can handle the loading state of the request by using the event system of the request class:
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
- resolve: (productId: string) => {
59
- return new RouteModelRequestResolver(
60
- new ProductShowRequest(productId).on<boolean>(RequestEvents.LOADING, (loading: boolean) => {
61
- const loadingStore = useLoadingStore()
62
- loadingStore.setLoading(loading)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueprint-ts/core",
3
- "version": "1.2.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