@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.
@@ -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,145 @@
1
+ import { computed, defineComponent, h, type Component as VueComponent, type VNode } from 'vue'
2
+ import { RouterView, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
3
+
4
+ type InjectionState = Record<string, { loading: boolean; error: Error | null }>
5
+
6
+ /**
7
+ * Drop-in replacement for `<router-view>` that handles route resource
8
+ * injection loading and error states.
9
+ *
10
+ * When any injected resource is loading → renders the route's `loadingComponent`.
11
+ * When any injected resource has an error → renders the route's `errorComponent`
12
+ * (receives `error` and `refresh` as props).
13
+ * Otherwise → passes `{ Component, route }` to the default scoped slot,
14
+ * just like `<RouterView>`.
15
+ *
16
+ * Error and loading components are defined in the route via `defineRoute`:
17
+ *
18
+ * ```ts
19
+ * defineRoute<{ credential: CredentialResource }>()({
20
+ * path: '/credentials/:credentialId',
21
+ * component: CredentialShowPage,
22
+ * errorComponent: GenericErrorPage,
23
+ * loadingComponent: LoadingSpinner,
24
+ * inject: {
25
+ * credential: {
26
+ * from: 'credentialId',
27
+ * resolve: (id) => new RouteResourceRequestResolver(new CredentialShowRequest(id)),
28
+ * },
29
+ * },
30
+ * })
31
+ * ```
32
+ *
33
+ * Set `lazy: false` to render the target component immediately (without
34
+ * waiting for resources). The component can then handle loading/error
35
+ * states itself via `useRouteResource()`:
36
+ *
37
+ * ```ts
38
+ * defineRoute<{ credential: CredentialResource }>()({
39
+ * path: '/credentials/:credentialId',
40
+ * component: CredentialShowPage,
41
+ * lazy: false,
42
+ * inject: { ... },
43
+ * })
44
+ * ```
45
+ *
46
+ * Usage in layout (supports the same v-slot pattern as RouterView):
47
+ * ```vue
48
+ * <RouteResourceBoundView v-slot="{ Component, route }">
49
+ * <component :is="Component" v-if="Component" :key="route.meta.usePathKey ? route.path : undefined" />
50
+ * <EmptyState v-else />
51
+ * </RouteResourceBoundView>
52
+ * ```
53
+ *
54
+ * Or without a slot (renders the matched component automatically):
55
+ * ```vue
56
+ * <RouteResourceBoundView />
57
+ * ```
58
+ */
59
+ export const RouteResourceBoundView = defineComponent({
60
+ name: 'RouteResourceBoundView',
61
+
62
+ setup(_, { slots }) {
63
+ const route = useRoute()
64
+
65
+ const injectionState = computed(() => {
66
+ return route.meta._injectionState as InjectionState | undefined
67
+ })
68
+
69
+ const firstError = computed((): Error | null => {
70
+ const state = injectionState.value
71
+ if (!state) return null
72
+
73
+ for (const key of Object.keys(state)) {
74
+ const entry = state[key]
75
+ if (entry?.error) return entry.error
76
+ }
77
+
78
+ return null
79
+ })
80
+
81
+ const anyLoading = computed((): boolean => {
82
+ const state = injectionState.value
83
+ if (!state) return false
84
+
85
+ return Object.keys(state).some((key) => state[key]?.loading)
86
+ })
87
+
88
+ const refresh = async () => {
89
+ const state = injectionState.value
90
+ if (!state) return
91
+
92
+ const erroredProps = Object.keys(state).filter((key) => state[key]?.error)
93
+
94
+ await Promise.all(erroredProps.map((propName) => route.meta.refresh?.(propName)))
95
+ }
96
+
97
+ /**
98
+ * Core render logic that decides which slot/component to show
99
+ * given the resolved Component and route from RouterView.
100
+ */
101
+ function renderContent(Component: VueComponent | undefined, resolvedRoute: RouteLocationNormalizedLoaded): VNode | VNode[] | undefined {
102
+ const isLazy = resolvedRoute.meta._lazy !== false
103
+
104
+ if (isLazy) {
105
+ if (firstError.value) {
106
+ const errorComponent = resolvedRoute.meta._errorComponent
107
+
108
+ if (errorComponent) {
109
+ return h(errorComponent, { error: firstError.value, refresh })
110
+ }
111
+
112
+ return slots['error']?.({ error: firstError.value, refresh })
113
+ }
114
+
115
+ if (anyLoading.value) {
116
+ const loadingComponent = resolvedRoute.meta._loadingComponent
117
+
118
+ if (loadingComponent) {
119
+ return h(loadingComponent)
120
+ }
121
+
122
+ return slots['loading']?.()
123
+ }
124
+ }
125
+
126
+ if (slots['default']) {
127
+ return slots['default']({ Component, route: resolvedRoute })
128
+ }
129
+
130
+ if (Component) {
131
+ return h(Component)
132
+ }
133
+
134
+ return undefined
135
+ }
136
+
137
+ return () => {
138
+ return h(RouterView, null, {
139
+ default: ({ Component, route: resolvedRoute }: { Component: VueComponent | undefined; route: RouteLocationNormalizedLoaded }) => {
140
+ return renderContent(Component, resolvedRoute)
141
+ }
142
+ })
143
+ }
144
+ }
145
+ })
@@ -1,3 +1,4 @@
1
+ import { markRaw, type Component } from 'vue'
1
2
  import { type RouteRecordRaw } from 'vue-router'
2
3
  import { type InjectConfig } from './types'
3
4
 
@@ -7,16 +8,43 @@ import { type InjectConfig } from './types'
7
8
  * - inject config types (must resolve Props[K])
8
9
  *
9
10
  * Usage:
10
- * export default defineRoute<{ product: ProductResource }>()({ ... })
11
+ * export default defineRoute<{ product: ProductResource }>()({
12
+ * path: '/products/:productId',
13
+ * component: ProductShowPage,
14
+ * errorComponent: GenericErrorPage,
15
+ * loadingComponent: LoadingSpinner,
16
+ * inject: { product: { from: 'productId', resolve: ... } },
17
+ * })
11
18
  */
12
19
  export function defineRoute<Props extends Record<string, unknown>>() {
13
20
  return function <
14
21
  T extends RouteRecordRaw & {
15
22
  inject?: InjectConfig<Props>
23
+ errorComponent?: Component
24
+ loadingComponent?: Component
25
+ lazy?: boolean
16
26
  }
17
27
  >(route: T): RouteRecordRaw {
18
28
  const originalProps = route.props
19
29
 
30
+ if (route.errorComponent) {
31
+ route.meta = route.meta ?? {}
32
+ route.meta._errorComponent = markRaw(route.errorComponent)
33
+ delete route.errorComponent
34
+ }
35
+
36
+ if (route.loadingComponent) {
37
+ route.meta = route.meta ?? {}
38
+ route.meta._loadingComponent = markRaw(route.loadingComponent)
39
+ delete route.loadingComponent
40
+ }
41
+
42
+ if (route.lazy !== undefined) {
43
+ route.meta = route.meta ?? {}
44
+ route.meta._lazy = route.lazy
45
+ delete route.lazy
46
+ }
47
+
20
48
  route.props = (to) => {
21
49
  const baseProps = typeof originalProps === 'function' ? originalProps(to) : originalProps === true ? to.params : (originalProps ?? {})
22
50
 
@@ -1,10 +1,11 @@
1
1
  import { defineRoute } from './defineRoute'
2
2
  import { installRouteInjection } from './installRouteInjection'
3
+ import { RouteResourceBoundView } from './RouteResourceBoundView'
3
4
  import { RouteResourceRequestResolver } from './RouteResourceRequestResolver'
4
5
  import { useRouteResource } from './useRouteResource'
5
6
 
6
7
  import type { DataRequest, InjectConfig, Resolver } from './types'
7
8
 
8
- export { defineRoute, installRouteInjection, RouteResourceRequestResolver, useRouteResource }
9
+ export { defineRoute, installRouteInjection, RouteResourceBoundView, RouteResourceRequestResolver, useRouteResource }
9
10
 
10
11
  export type { DataRequest, InjectConfig, Resolver }
@@ -7,17 +7,26 @@ type InjectRuntimeConfig = {
7
7
  getter: (payload: unknown) => unknown
8
8
  }
9
9
 
10
+ type CachedEntry = {
11
+ paramValue: string
12
+ payload: unknown
13
+ }
14
+
10
15
  /**
11
16
  * Installs the runtime part:
12
17
  * - resolves all route.inject entries before navigation completes
13
18
  * - stores results on to.meta._injectedProps
14
19
  * - ensures route props include injected results (so components receive them as props)
20
+ * - caches resolved resources so child routes inherit parent-resolved data
21
+ * without triggering redundant requests (as long as the param value is unchanged)
15
22
  *
16
23
  * Notes:
17
24
  * - This keeps router files clean: only an `inject` block per route.
18
25
  * - This is intentionally runtime-generic; type safety happens at route definition time.
19
26
  */
20
27
  export function installRouteInjection(router: Router) {
28
+ const cache: Record<string, CachedEntry> = {}
29
+
21
30
  router.beforeResolve(async (to) => {
22
31
  console.log('[Route Injection] Resolving route injections...')
23
32
 
@@ -25,7 +34,13 @@ export function installRouteInjection(router: Router) {
25
34
  to.meta._injectedProps = reactive({})
26
35
  }
27
36
 
28
- const resolvers: Record<string, () => Promise<unknown>> = {}
37
+ if (!to.meta._injectionState) {
38
+ to.meta._injectionState = reactive({})
39
+ }
40
+
41
+ const resolvers: Record<string, (options?: { silent?: boolean }) => Promise<unknown>> = {}
42
+ const activePropNames = new Set<string>()
43
+ const pendingResolvers: Array<() => Promise<void>> = []
29
44
 
30
45
  // Iterate through all matched route records (from parent to child)
31
46
  for (const record of to.matched) {
@@ -57,37 +72,93 @@ export function installRouteInjection(router: Router) {
57
72
  continue
58
73
  }
59
74
 
60
- // Define the refresh logic for this specific prop
61
- const resolveProp = async () => {
62
- const resolver = cfg.resolve(paramValue)
63
- let payload = await resolver.resolve()
64
- if (typeof cfg.getter === 'function') {
65
- payload = cfg.getter(payload)
66
- }
75
+ activePropNames.add(propName)
76
+
77
+ // Initialize state for this prop
78
+ const state = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
79
+ if (!state[propName]) {
80
+ state[propName] = reactive({ loading: false, error: null })
81
+ }
67
82
 
68
- // Updating the reactive object triggers the component re-render
69
- ;(to.meta._injectedProps as any)[propName] = payload
70
- return payload
83
+ // Define the refresh logic for this specific prop (always fetches fresh data)
84
+ const resolveProp = async (options?: { silent?: boolean }) => {
85
+ const propState = (to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>)[propName]!
86
+ if (!options?.silent) {
87
+ propState.loading = true
88
+ }
89
+ propState.error = null
90
+
91
+ try {
92
+ const resolver = cfg.resolve(paramValue)
93
+ let payload = await resolver.resolve()
94
+ if (typeof cfg.getter === 'function') {
95
+ payload = cfg.getter(payload)
96
+ }
97
+
98
+ // Update cache
99
+ cache[propName] = { paramValue, payload }
100
+
101
+ // Updating the reactive object triggers the component re-render
102
+ const injectedProps = to.meta._injectedProps as Record<string, unknown>
103
+ injectedProps[propName] = payload
104
+ return payload
105
+ } catch (e) {
106
+ propState.error = e instanceof Error ? e : new Error(String(e))
107
+ throw e
108
+ } finally {
109
+ propState.loading = false
110
+ }
71
111
  }
72
112
 
73
113
  resolvers[propName] = resolveProp
74
114
 
75
- await resolveProp()
115
+ // Reuse cached value if the param hasn't changed (e.g. navigating between child routes)
116
+ const cached = cache[propName]
117
+ if (cached && cached.paramValue === paramValue) {
118
+ console.debug(`[Route Injection] Using cached value for prop "${propName}" (param "${cfg.from}" unchanged)`)
119
+ const injectedPropsCache = to.meta._injectedProps as Record<string, unknown>
120
+ injectedPropsCache[propName] = cached.payload
76
121
 
77
- console.debug(`[Route Injection] Successfully resolved prop "${propName}" for route "${record.path}"`)
122
+ continue
123
+ }
124
+
125
+ // Set loading immediately and queue resolver (non-blocking)
126
+ const injectionState = to.meta._injectionState as Record<string, { loading: boolean; error: Error | null }>
127
+ injectionState[propName]!.loading = true
128
+ pendingResolvers.push(async () => {
129
+ try {
130
+ await resolveProp()
131
+ } catch (e) {
132
+ console.error(`[Route Injection] Failed to resolve prop "${propName}"`, e)
133
+ }
134
+ })
135
+
136
+ console.debug(`[Route Injection] Queued resolution for prop "${propName}" for route "${record.path}"`)
137
+ }
138
+ }
139
+
140
+ // Evict cache entries that are no longer part of the active route hierarchy
141
+ for (const key of Object.keys(cache)) {
142
+ if (!activePropNames.has(key)) {
143
+ delete cache[key]
78
144
  }
79
145
  }
80
146
 
81
147
  // Attach the resolvers and a global refresh helper to the route meta
82
148
  to.meta['_injectedResolvers'] = resolvers
83
- to.meta['refresh'] = async (propName: string) => {
149
+ to.meta['refresh'] = async (propName: string, options?: { silent?: boolean }) => {
84
150
  if (resolvers[propName]) {
85
- return await resolvers[propName]()
151
+ return await resolvers[propName](options)
86
152
  }
87
153
 
88
154
  console.warn(`[Route Injection] No resolver found for "${propName}"`)
89
155
 
90
156
  return undefined
91
157
  }
158
+
159
+ // Fire pending resolvers without blocking navigation
160
+ if (pendingResolvers.length > 0) {
161
+ Promise.all(pendingResolvers.map((fn) => fn()))
162
+ }
92
163
  })
93
164
  }
@@ -1,3 +1,4 @@
1
+ import { type Component } from 'vue'
1
2
  import { type RouteLocationNormalized } from 'vue-router'
2
3
 
3
4
  /**
@@ -42,7 +43,11 @@ declare module 'vue-router' {
42
43
  interface RouteMeta {
43
44
  _injectedProps?: Record<string, any>
44
45
  _injectedResolvers?: Record<string, () => Promise<any>>
45
- refresh?: (propName: string) => Promise<any>
46
+ _injectionState?: Record<string, { loading: boolean; error: Error | null }>
47
+ _errorComponent?: Component
48
+ _loadingComponent?: Component
49
+ _lazy?: boolean
50
+ refresh?: (propName: string, options?: { silent?: boolean }) => Promise<any>
46
51
  inject?: Record<string, unknown>
47
52
  }
48
53
  }
@@ -1,14 +1,24 @@
1
+ import { computed, type ComputedRef } from 'vue'
1
2
  import { useRoute } from 'vue-router'
2
3
 
3
- export function useRouteResource() {
4
+ type InjectionState = Record<string, { loading: boolean; error: Error | null }>
5
+
6
+ export function useRouteResource(propName: string) {
4
7
  const route = useRoute()
5
8
 
6
- const refresh = async (propName: string) => {
7
- return await route.meta.refresh?.(propName)
9
+ const refresh = async (options?: { silent?: boolean }) => {
10
+ return await route.meta.refresh?.(propName, options)
8
11
  }
9
12
 
10
- // If emit is passed, we can wrap it or just provide a helper
11
- // to be used like: onRefresh('product', () => ...)
12
- // but the most direct way for you is:
13
- return { refresh }
13
+ const isLoading: ComputedRef<boolean> = computed(() => {
14
+ const state = route.meta._injectionState as InjectionState | undefined
15
+ return state?.[propName]?.loading ?? false
16
+ })
17
+
18
+ const error: ComputedRef<Error | null> = computed(() => {
19
+ const state = route.meta._injectionState as InjectionState | undefined
20
+ return state?.[propName]?.error ?? null
21
+ })
22
+
23
+ return { refresh, isLoading, error }
14
24
  }
@@ -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
+ })