@swiss-ai-hub/web 0.298.2 → 0.298.3

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.
@@ -105,6 +105,7 @@
105
105
  :label="rep.label"
106
106
  :add-label="rep.addLabel"
107
107
  :children-schema="rep.childrenSchema"
108
+ :default-item="rep.defaultItem"
108
109
  :min="rep.min"
109
110
  :max="rep.max"
110
111
  @update:model-value="setRepeaterData(rep.path, $event)"
@@ -135,7 +136,7 @@
135
136
  </template>
136
137
 
137
138
  <script setup lang="ts">
138
- import { type FormElement, normalizeFormLocaleStrings } from '@core/composables/form/useFormKitTransform'
139
+ import { type FormElement, serializeFormData } from '@core/composables/form/useFormKitTransform'
139
140
  import { getNode } from '@formkit/core'
140
141
 
141
142
  const props = defineProps<{
@@ -168,8 +169,6 @@ const {
168
169
  getRepeaterStepIndex,
169
170
  getRepeaterData,
170
171
  setRepeaterData,
171
- cleanFormData,
172
- coerceNullableToggles,
173
172
  applyInitialData,
174
173
  resetForm,
175
174
  } = useCreateInstanceForm({
@@ -219,9 +218,7 @@ function triggerFormSubmit() {
219
218
 
220
219
  async function handleFormSubmit() {
221
220
  try {
222
- const cleanedData = cleanFormData(formData.value)
223
- const coerced = coerceNullableToggles(cleanedData, configForm.value as FormElement[])
224
- const normalizedConfig = normalizeFormLocaleStrings(coerced)
221
+ const normalizedConfig = serializeFormData(formData.value, configForm.value as FormElement[])
225
222
  const agentId = normalizedConfig.agent_id as string
226
223
  await createAgentInstance({
227
224
  agentClass: selectedClass.value,
@@ -16,7 +16,7 @@
16
16
  v-for="(val, key) in event.event.parameters"
17
17
  :key="key"
18
18
  >
19
- <Button :label="useChangeCase(key, 'capitalCase')" />
19
+ <Button :label="capitalCase(key)" />
20
20
  <InputText
21
21
  :placeholder="val"
22
22
  readonly
@@ -27,7 +27,7 @@
27
27
  </template>
28
28
 
29
29
  <script setup lang="ts">
30
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
30
+ import { capitalCase } from 'change-case'
31
31
 
32
32
  import type { ThreadDto, ToolEvent, ContextualizedAgentEvent } from '@core/sdk/client'
33
33
 
@@ -93,7 +93,7 @@
93
93
 
94
94
  <script setup lang="ts">
95
95
  import { getAgentClasses, getAgentClassInstances } from '@core/sdk/client'
96
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
96
+ import { capitalCase } from 'change-case'
97
97
 
98
98
  import type { AgentClassDto, FullAgentInstanceDto, LocaleString } from '@core/sdk/client'
99
99
 
@@ -200,7 +200,7 @@ function emitValue(agentClass: string, agentId: string) {
200
200
  const classOptions = computed<ClassOption[]>(() =>
201
201
  agentClasses.value.map(cls => ({
202
202
  name: cls.agent_class,
203
- displayName: getLocalizedValue(cls.name, locale.value) || useChangeCase(cls.agent_class, 'capitalCase'),
203
+ displayName: getLocalizedValue(cls.name, locale.value) || capitalCase(cls.agent_class),
204
204
  icon: cls.icon || 'mage:robot',
205
205
  })),
206
206
  )
@@ -27,6 +27,7 @@
27
27
  :label="rep.label"
28
28
  :add-label="rep.addLabel"
29
29
  :children-schema="rep.childrenSchema"
30
+ :default-item="rep.defaultItem"
30
31
  :min="rep.min"
31
32
  :max="rep.max"
32
33
  @update:model-value="setRepeaterData(rep.path, $event)"
@@ -38,12 +39,10 @@
38
39
  <script setup lang="ts">
39
40
  import {
40
41
  buildFormKitSchema,
41
- coerceNullableToggles,
42
42
  extractRepeaterConfigs,
43
43
  getNestedValue,
44
- normalizeFormLocaleStrings,
45
- seedFormDefaults,
46
- seedNullableToggles,
44
+ hydrateFormData,
45
+ serializeFormData,
47
46
  setNestedValue,
48
47
  type FormElement,
49
48
  type RepeaterConfig,
@@ -61,8 +60,7 @@ const props = defineProps<{
61
60
  }>()
62
61
 
63
62
  function hydrate(raw: Record<string, unknown>): Record<string, unknown> {
64
- const seeded = seedNullableToggles(raw, props.form as FormElement[])
65
- return seedFormDefaults(seeded, props.form as FormElement[])
63
+ return hydrateFormData(raw, props.form as FormElement[])
66
64
  }
67
65
 
68
66
  const data = ref<Record<string, unknown>>(hydrate(props.initialData || {}))
@@ -110,9 +108,7 @@ function setRepeaterData(path: string, value: Record<string, unknown>[]): void {
110
108
  }
111
109
 
112
110
  async function submitHandler() {
113
- const coerced = coerceNullableToggles(data.value, props.form as FormElement[])
114
- const normalizedData = normalizeFormLocaleStrings(coerced)
115
- emit('submit', normalizedData)
111
+ emit('submit', serializeFormData(data.value, props.form as FormElement[]))
116
112
  }
117
113
  </script>
118
114
 
@@ -26,7 +26,7 @@
26
26
 
27
27
  <script setup lang="ts">
28
28
  import { getDatabases } from '@core/sdk/client'
29
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
29
+ import { capitalCase } from 'change-case'
30
30
 
31
31
  import type { DatabaseDto } from '@core/sdk/client'
32
32
 
@@ -64,7 +64,7 @@ const selectedDatabases = computed({
64
64
  const databaseOptions = computed<DatabaseOption[]>(() =>
65
65
  databases.value.map(db => ({
66
66
  name: db.name,
67
- displayName: db.display_name || useChangeCase(db.name, 'capitalCase').value,
67
+ displayName: db.display_name || capitalCase(db.name),
68
68
  })),
69
69
  )
70
70
 
@@ -7,7 +7,7 @@
7
7
  <div class="flex flex-col gap-3">
8
8
  <div
9
9
  v-for="(item, index) in items"
10
- :key="index"
10
+ :key="rowKeys[index]"
11
11
  class="relative rounded-lg border border-surface-200 p-4 dark:border-surface-700"
12
12
  >
13
13
  <div class="mb-3 flex items-center justify-between">
@@ -27,9 +27,9 @@
27
27
 
28
28
  <FormKit
29
29
  v-if="modelValue"
30
- :id="`__validate__${name}__${index}`"
30
+ :id="`__validate__${name}__${rowKeys[index]}`"
31
31
  v-model="modelValue[index]"
32
- :name="`__validate__${name}__${index}`"
32
+ :name="`__validate__${name}__${rowKeys[index]}`"
33
33
  type="group"
34
34
  >
35
35
  <FormKitSchema
@@ -53,6 +53,8 @@
53
53
  </template>
54
54
 
55
55
  <script setup lang="ts">
56
+ import { cloneDeep } from 'lodash-es'
57
+
56
58
  import type { FormKitSchemaNode } from '@formkit/core'
57
59
 
58
60
  const props = defineProps<{
@@ -60,6 +62,7 @@ const props = defineProps<{
60
62
  label?: string
61
63
  addLabel?: string
62
64
  childrenSchema: FormKitSchemaNode[]
65
+ defaultItem?: Record<string, unknown>
63
66
  min?: number
64
67
  max?: number
65
68
  }>()
@@ -68,6 +71,22 @@ const modelValue = defineModel<Record<string, unknown>[]>({ default: () => [] })
68
71
 
69
72
  const items = computed(() => modelValue.value || [])
70
73
 
74
+ function makeRowKey(): string {
75
+ return crypto.randomUUID()
76
+ }
77
+
78
+ // One stable key per row, used both as the Vue `:key` and inside the FormKit group id/name.
79
+ // Index-based keys reindex survivors when a non-last row is removed, rebinding each FormKit
80
+ // group to a different row's data and corrupting the form; stable keys avoid that. add/remove
81
+ // keep the array aligned to the rows; the watch only reconciles external model replacement
82
+ // (e.g. form load), preserving existing keys positionally.
83
+ const rowKeys = ref<string[]>(items.value.map(makeRowKey))
84
+
85
+ watch(() => items.value.length, (length) => {
86
+ if (length === rowKeys.value.length) return
87
+ rowKeys.value = Array.from({ length }, (_, index) => rowKeys.value[index] ?? makeRowKey())
88
+ })
89
+
71
90
  const isAddDisabled = computed(() => {
72
91
  if (typeof props.max !== 'number') return false
73
92
  return items.value.length >= props.max
@@ -83,11 +102,13 @@ function addItem() {
83
102
  if (!modelValue.value) {
84
103
  modelValue.value = []
85
104
  }
86
- modelValue.value.push({})
105
+ rowKeys.value.push(makeRowKey())
106
+ modelValue.value.push(props.defaultItem ? cloneDeep(props.defaultItem) : {})
87
107
  }
88
108
 
89
109
  function removeItem(index: number) {
90
110
  if (!modelValue.value || isRemoveDisabled.value) return
111
+ rowKeys.value.splice(index, 1)
91
112
  modelValue.value = modelValue.value.filter((_, i) => i !== index)
92
113
  }
93
114
  </script>
@@ -96,7 +96,7 @@
96
96
  <script setup lang="ts">
97
97
  import ChipsInput from '@core/components/FormKit/ChipsInput.vue'
98
98
  import { getDatabases } from '@core/sdk/client'
99
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
99
+ import { capitalCase } from 'change-case'
100
100
 
101
101
  import type { DatabaseDto } from '@core/sdk/client'
102
102
 
@@ -200,7 +200,7 @@ function emitValue(collectionName: string, namespaces: string[], allowedFilterFi
200
200
  const databaseOptions = computed<DatabaseOption[]>(() =>
201
201
  databases.value.map(db => ({
202
202
  name: db.name,
203
- displayName: db.display_name || useChangeCase(db.name, 'capitalCase'),
203
+ displayName: db.display_name || capitalCase(db.name),
204
204
  })),
205
205
  )
206
206
 
@@ -213,7 +213,7 @@ const namespaceOptions = computed<NamespaceOption[]>(() => {
213
213
  return []
214
214
  return db.namespaces.map(ns => ({
215
215
  name: ns.name,
216
- displayName: ns.display_name || useChangeCase(ns.name, 'capitalCase'),
216
+ displayName: ns.display_name || capitalCase(ns.name),
217
217
  }))
218
218
  })
219
219
 
@@ -129,7 +129,7 @@
129
129
  </template>
130
130
 
131
131
  <script setup lang="ts">
132
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
132
+ import { capitalCase } from 'change-case'
133
133
 
134
134
  interface Props {
135
135
  visible: boolean
@@ -146,11 +146,11 @@ const props = withDefaults(defineProps<Props>(), {
146
146
  const { t } = useI18n()
147
147
 
148
148
  const databaseDisplayName = computed(() => {
149
- return props.databaseDisplayName || useChangeCase(props.database, 'capitalCase').value
149
+ return props.databaseDisplayName || capitalCase(props.database)
150
150
  })
151
151
 
152
152
  const namespaceDisplayName = computed(() => {
153
- return props.namespaceDisplayName || useChangeCase(props.namespace, 'capitalCase').value
153
+ return props.namespaceDisplayName || capitalCase(props.namespace)
154
154
  })
155
155
 
156
156
  const emit = defineEmits<{
@@ -63,7 +63,7 @@
63
63
  </template>
64
64
 
65
65
  <script setup lang="ts">
66
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
66
+ import { capitalCase } from 'change-case'
67
67
 
68
68
  import type { NamespaceDto } from '@core/sdk/client'
69
69
 
@@ -82,7 +82,7 @@ const { t } = useI18n()
82
82
 
83
83
  const displayName = computed(() => {
84
84
  // Use display_name if available, otherwise fall back to formatted technical name
85
- return props.namespace.display_name || useChangeCase(props.namespace.name, 'capitalCase')
85
+ return props.namespace.display_name || capitalCase(props.namespace.name)
86
86
  })
87
87
 
88
88
  const createdAt = computed(() => {
@@ -114,7 +114,7 @@
114
114
  </template>
115
115
 
116
116
  <script setup lang="ts">
117
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
117
+ import { capitalCase } from 'change-case'
118
118
 
119
119
  import type { CreateNamespaceRequest, DatabaseDto } from '@core/sdk/client'
120
120
 
@@ -146,7 +146,7 @@ const databaseOptions = computed(() =>
146
146
  .filter(db => !db.auto_sync)
147
147
  .map(db => ({
148
148
  name: db.name,
149
- displayName: useChangeCase(db.name, 'capitalCase'),
149
+ displayName: capitalCase(db.name),
150
150
  })),
151
151
  )
152
152
  const nameValidationError = computed(() => {
@@ -104,6 +104,7 @@
104
104
  :label="rep.label"
105
105
  :add-label="rep.addLabel"
106
106
  :children-schema="rep.childrenSchema"
107
+ :default-item="rep.defaultItem"
107
108
  :min="rep.min"
108
109
  :max="rep.max"
109
110
  @update:model-value="setRepeaterData(rep.path, $event)"
@@ -134,7 +135,7 @@
134
135
  </template>
135
136
 
136
137
  <script setup lang="ts">
137
- import { type FormElement, normalizeFormLocaleStrings } from '@core/composables/form/useFormKitTransform'
138
+ import { type FormElement, serializeFormData } from '@core/composables/form/useFormKitTransform'
138
139
  import { getNode } from '@formkit/core'
139
140
 
140
141
  const props = defineProps<{
@@ -166,8 +167,6 @@ const {
166
167
  getRepeaterStepIndex,
167
168
  getRepeaterData,
168
169
  setRepeaterData,
169
- cleanFormData,
170
- coerceNullableToggles,
171
170
  applyInitialData,
172
171
  resetForm,
173
172
  } = useCreateInstanceForm({
@@ -212,9 +211,7 @@ function triggerFormSubmit() {
212
211
 
213
212
  async function handleFormSubmit() {
214
213
  try {
215
- const cleanedData = cleanFormData(formData.value)
216
- const coerced = coerceNullableToggles(cleanedData, configForm.value as FormElement[])
217
- const normalizedConfig = normalizeFormLocaleStrings(coerced)
214
+ const normalizedConfig = serializeFormData(formData.value, configForm.value as FormElement[])
218
215
  const processId = normalizedConfig.process_id as string
219
216
  await createProcessInstance({
220
217
  processClass: selectedClass.value,
@@ -1,18 +1,13 @@
1
- import { merge } from 'lodash-es'
2
-
3
1
  import {
4
2
  type FormElement,
5
3
  type GroupConfig,
6
4
  type RepeaterConfig,
7
5
  buildFormKitSchema,
8
6
  categorizeFormElements,
9
- coerceNullableToggles,
10
7
  extractGroupConfigs,
11
8
  extractRepeaterConfigs,
12
- getFormkitType,
13
9
  getNestedValue,
14
- seedFormDefaults,
15
- seedNullableToggles,
10
+ hydrateFormData,
16
11
  setNestedValue,
17
12
  } from './useFormKitTransform'
18
13
 
@@ -24,51 +19,6 @@ export interface ClassDataLike {
24
19
  templates?: Array<Record<string, unknown>>
25
20
  }
26
21
 
27
- /**
28
- * Drops keys whose matching form-schema node is a group/repeater and whose incoming
29
- * value is `null`. FormKit rejects `null` for group values (must be an object) and for
30
- * repeater values (must be an array), so template payloads that serialise optional
31
- * nested configs as `null` (Pydantic `Form | None = None`) would otherwise throw during
32
- * hydration.
33
- */
34
- export function stripNullsForGroups(
35
- data: Record<string, unknown>,
36
- elements: FormElement[],
37
- ): Record<string, unknown> {
38
- const result: Record<string, unknown> = {}
39
-
40
- for (const [key, value] of Object.entries(data)) {
41
- const element = elements.find(el => el.name === key)
42
- if (!element) {
43
- result[key] = value
44
- continue
45
- }
46
- const formkitType = getFormkitType(element)
47
-
48
- if ((formkitType === 'group' || formkitType === 'repeater') && value === null) {
49
- continue
50
- }
51
-
52
- const children = (element.children as FormElement[] | undefined) ?? []
53
-
54
- if (formkitType === 'group' && value && typeof value === 'object' && !Array.isArray(value)) {
55
- result[key] = stripNullsForGroups(value as Record<string, unknown>, children)
56
- }
57
- else if (formkitType === 'repeater' && Array.isArray(value)) {
58
- result[key] = value.map(item =>
59
- item && typeof item === 'object' && !Array.isArray(item)
60
- ? stripNullsForGroups(item as Record<string, unknown>, children)
61
- : item,
62
- )
63
- }
64
- else {
65
- result[key] = value
66
- }
67
- }
68
-
69
- return result
70
- }
71
-
72
22
  export interface CreateInstanceFormOptions<T extends ClassDataLike> {
73
23
  /** Reactive list of available class definitions (agent classes, process classes, etc.) */
74
24
  classes: Ref<T[] | undefined>
@@ -85,9 +35,10 @@ export interface CreateInstanceFormOptions<T extends ClassDataLike> {
85
35
  /**
86
36
  * Shared form logic for creating agent/process instances from class definitions.
87
37
  *
88
- * Handles class selection, FormKit schema generation, stepper navigation,
89
- * and form data lifecycle. Template/clone pre-filling is done via applyInitialData().
90
- * Domain-specific submission logic stays in the calling component.
38
+ * Handles class selection, FormKit schema generation, stepper navigation, and form data
39
+ * lifecycle. Hydration is shared with the edit form via `hydrateFormData` (and submission via
40
+ * `serializeFormData`) so create and edit behave identically. Template/clone pre-filling is
41
+ * done via applyInitialData(); domain-specific submission stays in the calling component.
91
42
  */
92
43
  export function useCreateInstanceForm<T extends ClassDataLike>(options: CreateInstanceFormOptions<T>) {
93
44
  const { classes, classField, initialClass, locale } = options
@@ -144,81 +95,15 @@ export function useCreateInstanceForm<T extends ClassDataLike>(options: CreateIn
144
95
  }
145
96
 
146
97
  watch(selectedClassData, (newClass) => {
147
- if (newClass?.form && newClass.form.length > 0) {
148
- const base = initializeGroupData(configForm.value as FormElement[], {})
149
- formData.value = seedFormDefaults(base, configForm.value as FormElement[])
150
- }
151
- else {
152
- formData.value = {}
153
- }
98
+ formData.value = newClass?.form && newClass.form.length > 0
99
+ ? hydrateFormData({}, configForm.value as FormElement[])
100
+ : {}
154
101
  }, { immediate: true })
155
102
 
156
- function initializeElementData(
157
- element: FormElement,
158
- result: Record<string, unknown>,
159
- recursiveFn: (elements: FormElement[], data: Record<string, unknown>) => Record<string, unknown>,
160
- ): void {
161
- const formkitType = getFormkitType(element)
162
- const name = element.name as string
163
- const children = element.children as FormElement[] | undefined
164
- const hasChildren = children && Array.isArray(children)
165
-
166
- if (formkitType === 'group') {
167
- result[name] = result[name] ?? {}
168
- if (hasChildren) {
169
- result[name] = recursiveFn(children, result[name] as Record<string, unknown>)
170
- }
171
- }
172
- else if (formkitType === 'repeater') {
173
- result[name] = result[name] ?? []
174
- if (Array.isArray(result[name]) && hasChildren) {
175
- result[name] = (result[name] as Record<string, unknown>[]).map(item => recursiveFn(children, item))
176
- }
177
- }
178
- }
179
-
180
- function initializeGroupData(
181
- formElements: FormElement[],
182
- data: Record<string, unknown>,
183
- ): Record<string, unknown> {
184
- const result = { ...data }
185
- for (const element of formElements) {
186
- initializeElementData(element, result, initializeGroupData)
187
- }
188
- return result
189
- }
190
-
191
- function cleanFormData(data: Record<string, unknown>): Record<string, unknown> {
192
- const result: Record<string, unknown> = {}
193
- // FormKit artifacts that should be stripped from submissions
194
- const formkitArtifacts = new Set(['slots'])
195
-
196
- for (const [key, value] of Object.entries(data)) {
197
- if (formkitArtifacts.has(key)) continue
198
- // Strip the internal repeater validation mirror written by FormKit/Repeater.vue.
199
- if (key.startsWith('__validate__')) continue
200
-
201
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
202
- result[key] = cleanFormData(value as Record<string, unknown>)
203
- }
204
- else {
205
- result[key] = value
206
- }
207
- }
208
-
209
- return result
210
- }
211
-
212
103
  function applyInitialData(data: Record<string, unknown>) {
213
- // Seed toggles from the raw data so that `field: null` in a template becomes
214
- // `__field__enabled: false`. If we seeded after stripping nulls and merging
215
- // against the `{}` placeholder from initializeGroupData, every nullable group
216
- // would look truthy and the toggle would come up enabled.
217
- const seeded = seedNullableToggles(data, configForm.value as FormElement[])
218
- const base = initializeGroupData(configForm.value as FormElement[], {})
219
- const withDefaults = seedFormDefaults(base, configForm.value as FormElement[])
220
- const sanitized = stripNullsForGroups(seeded, configForm.value as FormElement[])
221
- formData.value = merge(withDefaults, sanitized)
104
+ // Hydrate from the template/clone data: nullable toggles follow the data's null-ness,
105
+ // missing leaves fall back to their backend defaults identical to the edit form.
106
+ formData.value = hydrateFormData(data, configForm.value as FormElement[])
222
107
  }
223
108
 
224
109
  function resetForm() {
@@ -242,9 +127,6 @@ export function useCreateInstanceForm<T extends ClassDataLike>(options: CreateIn
242
127
  getRepeaterStepIndex,
243
128
  getRepeaterData,
244
129
  setRepeaterData,
245
- initializeGroupData,
246
- cleanFormData,
247
- coerceNullableToggles,
248
130
  applyInitialData,
249
131
  resetForm,
250
132
  }
@@ -6,6 +6,10 @@ export interface RepeaterConfig {
6
6
  label?: string
7
7
  addLabel?: string
8
8
  childrenSchema: FormKitSchemaNode[]
9
+ // Seeded default for a freshly added item. `childrenSchema` is transformed and has its
10
+ // `value` defaults stripped (see EXCLUDED_FIELDS), so a new item must be cloned from this
11
+ // instead of `{}` — otherwise fields like "documents to retrieve" render empty.
12
+ defaultItem: Record<string, unknown>
9
13
  min?: number
10
14
  max?: number
11
15
  }
@@ -225,6 +229,8 @@ const EXCLUDED_FIELDS = new Set([
225
229
  'placeholder', // Transformed via getLocalizedString
226
230
  'children', // Handled separately for recursion
227
231
  'nullable', // Wrapper-level signal for the transform; never a FormKit/PrimeVue prop
232
+ 'defaultEnabled', // Wrapper-level signal (initial nullable-toggle state); never a FormKit prop
233
+ 'default_enabled', // snake_case form of the above
228
234
  // Backend serialises the Pydantic default into element.value (form duality). FormKit pushes
229
235
  // schema `value` up to the parent v-model on input registration, which would clobber the
230
236
  // loaded data with the backend default. Defaults belong in data, seeded via seedFormDefaults.
@@ -449,33 +455,42 @@ export function extractRepeaterConfigs(
449
455
  const formkitType = getFormkitType(element)
450
456
  const elementName = element.name as string
451
457
 
452
- if (formkitType === 'repeater') {
453
- const childrenSchema = (element.children as FormElement[] || []).flatMap(
454
- child => transformElementForRepeater(child, locale),
455
- ) as FormKitSchemaNode[]
456
-
457
- // Build full path for nested data access
458
- const fullPath = parentPath ? `${parentPath}.${elementName}` : elementName
459
-
460
- repeaters.push({
461
- name: elementName,
462
- path: fullPath,
463
- label: getLocalizedString(element.label, locale),
464
- addLabel: getLocalizedString(element.addLabel || element.add_label, locale),
465
- childrenSchema,
466
- min: element.min as number | undefined,
467
- max: element.max as number | undefined,
468
- })
458
+ // Isolate each element so one malformed repeater (or a group containing one) is skipped
459
+ // rather than throwing out of the computed and breaking every repeater on the form.
460
+ try {
461
+ if (formkitType === 'repeater') {
462
+ const childrenSchema = (element.children as FormElement[] || []).flatMap(
463
+ child => transformElementForRepeater(child, locale),
464
+ ) as FormKitSchemaNode[]
465
+
466
+ // Build full path for nested data access
467
+ const fullPath = parentPath ? `${parentPath}.${elementName}` : elementName
468
+
469
+ const itemChildren = (element.children as FormElement[]) || []
470
+ repeaters.push({
471
+ name: elementName,
472
+ path: fullPath,
473
+ label: getLocalizedString(element.label, locale),
474
+ addLabel: getLocalizedString(element.addLabel || element.add_label, locale),
475
+ childrenSchema,
476
+ defaultItem: seedFormDefaults({}, itemChildren),
477
+ min: element.min as number | undefined,
478
+ max: element.max as number | undefined,
479
+ })
480
+ }
481
+ else if (formkitType === 'group' && element.children) {
482
+ // Recursively search for repeaters inside groups, passing the current path
483
+ const groupPath = parentPath ? `${parentPath}.${elementName}` : elementName
484
+ const nestedRepeaters = extractRepeaterConfigs(
485
+ element.children as FormElement[],
486
+ locale,
487
+ groupPath,
488
+ )
489
+ repeaters.push(...nestedRepeaters)
490
+ }
469
491
  }
470
- else if (formkitType === 'group' && element.children) {
471
- // Recursively search for repeaters inside groups, passing the current path
472
- const groupPath = parentPath ? `${parentPath}.${elementName}` : elementName
473
- const nestedRepeaters = extractRepeaterConfigs(
474
- element.children as FormElement[],
475
- locale,
476
- groupPath,
477
- )
478
- repeaters.push(...nestedRepeaters)
492
+ catch (error) {
493
+ console.error(`Error extracting repeater "${elementName ?? '<unknown>'}":`, error)
479
494
  }
480
495
  }
481
496
 
@@ -491,13 +506,19 @@ export function buildFormKitSchema(
491
506
  ): FormKitSchemaNode[] {
492
507
  if (!formElements || formElements.length === 0) return []
493
508
 
494
- try {
495
- return formElements.flatMap(el => transformElementToSchema(el, options)) as FormKitSchemaNode[]
496
- }
497
- catch (error) {
498
- console.error('Error transforming schema:', error)
499
- return []
500
- }
509
+ // Isolate each element: a single malformed element is skipped (and logged) instead of
510
+ // collapsing the whole section to []. A blanket try/catch here meant one throwing input
511
+ // took every sibling down with it — e.g. a bad config field wiped the entire Basic Info
512
+ // step (agent_id, name, …), leaving an unusable form.
513
+ return formElements.flatMap((element) => {
514
+ try {
515
+ return transformElementToSchema(element, options)
516
+ }
517
+ catch (error) {
518
+ console.error(`Error transforming form element "${(element?.name as string) ?? '<unknown>'}":`, error)
519
+ return []
520
+ }
521
+ }) as FormKitSchemaNode[]
501
522
  }
502
523
 
503
524
  const LOCALE_KEYS = new Set(['de', 'en', 'fr', 'it'])
@@ -618,13 +639,15 @@ export function coerceNullableToggles(
618
639
  * process edit forms.
619
640
  */
620
641
  /**
621
- * Seed a group field's value: leave a disabled nullable group as `null`; otherwise
622
- * materialise its children's defaults (starting from the existing object when present).
623
- * `coerceNullableToggles` re-nullifies disabled subtrees at submit time, so seeding a
624
- * group whose toggle will end up off is safe.
642
+ * Seed a group field's value: always materialise its children's defaults (starting from
643
+ * the existing object when present, `{}` otherwise including for a saved `null`). A null
644
+ * nullable group must still hold an object so FormKit can mount and render its children
645
+ * when the user flips the "Enable" toggle on. Visibility is governed by the synthetic
646
+ * toggle (seeded earlier from the raw null-ness by `seedNullableToggles`), and
647
+ * `coerceNullableToggles` re-nullifies disabled subtrees at submit time — so a materialised
648
+ * but disabled group is never persisted.
625
649
  */
626
- function seedGroupDefault(value: unknown, children: FormElement[]): Record<string, unknown> | null {
627
- if (value === null) return null
650
+ function seedGroupDefault(value: unknown, children: FormElement[]): Record<string, unknown> {
628
651
  const groupValue = value && typeof value === 'object' && !Array.isArray(value)
629
652
  ? value as Record<string, unknown>
630
653
  : {}
@@ -675,8 +698,11 @@ export function seedFormDefaults(
675
698
  }
676
699
 
677
700
  /**
678
- * Recursively seeds synthetic toggle values from initial data: toggle is on iff the
679
- * matching field was non-null/undefined in the source data.
701
+ * Recursively seeds synthetic toggle values from initial data. When the field is present in
702
+ * the source data the toggle follows its null-ness (edit/clone). When it is absent — a fresh
703
+ * form — the toggle falls back to the backend's `default_enabled` (the field's data default is
704
+ * non-null), so a nullable field that ships a default (e.g. a prompt, or org_memory) comes up
705
+ * enabled while a `None`-defaulting one (e.g. reranking_config) stays off.
680
706
  */
681
707
  export function seedNullableToggles(
682
708
  data: Record<string, unknown>,
@@ -687,7 +713,9 @@ export function seedNullableToggles(
687
713
  for (const element of elements) {
688
714
  const name = element.name as string
689
715
  if (element.nullable === true) {
690
- result[nullableToggleName(name)] = result[name] !== null && result[name] !== undefined
716
+ result[nullableToggleName(name)] = name in result
717
+ ? result[name] !== null && result[name] !== undefined
718
+ : (element.defaultEnabled ?? element.default_enabled) === true
691
719
  }
692
720
 
693
721
  const formkitType = getFormkitType(element)
@@ -709,6 +737,45 @@ export function seedNullableToggles(
709
737
  return result
710
738
  }
711
739
 
740
+ /**
741
+ * Strips FormKit submission artifacts: the `slots` helper key and the repeater validation
742
+ * mirror keys (`__validate__*`) registered by Repeater.vue. Recurses into nested group objects.
743
+ */
744
+ export function cleanFormData(data: Record<string, unknown>): Record<string, unknown> {
745
+ const result: Record<string, unknown> = {}
746
+ for (const [key, value] of Object.entries(data)) {
747
+ if (key === 'slots' || key.startsWith('__validate__')) continue
748
+ result[key] = value !== null && typeof value === 'object' && !Array.isArray(value)
749
+ ? cleanFormData(value as Record<string, unknown>)
750
+ : value
751
+ }
752
+ return result
753
+ }
754
+
755
+ /**
756
+ * Raw saved/template/empty data → a fully hydrated FormKit model: nullable toggles seeded from
757
+ * the data's null-ness (or `default_enabled` on a fresh form), then groups materialised and
758
+ * leaf defaults filled. Single entry point so create and edit forms hydrate identically.
759
+ */
760
+ export function hydrateFormData(
761
+ raw: Record<string, unknown>,
762
+ elements: FormElement[],
763
+ ): Record<string, unknown> {
764
+ return seedFormDefaults(seedNullableToggles(raw, elements), elements)
765
+ }
766
+
767
+ /**
768
+ * FormKit model → submission payload: disabled nullable subtrees nulled and synthetic toggle
769
+ * keys dropped (coerceNullableToggles), LocaleStrings normalised, FormKit artifacts stripped.
770
+ * Single entry point so create and edit forms serialise identically.
771
+ */
772
+ export function serializeFormData(
773
+ data: Record<string, unknown>,
774
+ elements: FormElement[],
775
+ ): Record<string, unknown> {
776
+ return cleanFormData(normalizeFormLocaleStrings(coerceNullableToggles(data, elements)))
777
+ }
778
+
712
779
  /**
713
780
  * Categorizes form elements into simple inputs, groups, and repeaters.
714
781
  * Used for organizing form elements into stepper steps.
@@ -752,9 +819,11 @@ export function extractGroupConfigs(
752
819
  const groups: GroupConfig[] = []
753
820
 
754
821
  for (const element of formElements) {
755
- const formkitType = getFormkitType(element)
822
+ if (getFormkitType(element) !== 'group') continue
756
823
 
757
- if (formkitType === 'group') {
824
+ // Isolate each group so a single malformed group is skipped rather than throwing out
825
+ // of the computed and blanking the whole step list.
826
+ try {
758
827
  const schema = transformElementToSchema(element, { locale })
759
828
  const schemaArray = Array.isArray(schema) ? schema : [schema]
760
829
 
@@ -764,6 +833,9 @@ export function extractGroupConfigs(
764
833
  schema: schemaArray,
765
834
  })
766
835
  }
836
+ catch (error) {
837
+ console.error(`Error transforming group "${(element?.name as string) ?? '<unknown>'}":`, error)
838
+ }
767
839
  }
768
840
 
769
841
  return groups
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "license": "AGPL-3.0-or-later",
4
4
  "author": "bbv Software Services AG (https://www.bbv.ch)",
5
5
  "type": "module",
6
- "version": "0.298.2",
6
+ "version": "0.298.3",
7
7
  "description": "Swiss AI Hub - Admin & Management UI (Nuxt 3 layer)",
8
8
  "main": "./nuxt.config.ts",
9
9
  "repository": {
@@ -34,10 +34,6 @@
34
34
  </template>
35
35
 
36
36
  <script setup lang="ts">
37
- import type { AgentConfigDtoReadable } from '@core/sdk/client'
38
-
39
- type FormElement = NonNullable<AgentConfigDtoReadable['form']>[number]
40
-
41
37
  const route = useRoute()
42
38
  const { tenantId } = useTenant()
43
39
  const { agentInstance, agentInstanceIsLoading } = useAgentInstance()
@@ -47,42 +43,14 @@ const toast = useToast()
47
43
 
48
44
  const configForm = computed(() => agentInstance.value?.agent_config?.form || [])
49
45
 
50
- /**
51
- * Recursively initializes nested Group values with empty objects based on form schema.
52
- * FormKit Groups require object values - they cannot be null or undefined.
53
- * This ensures all Group elements have at least an empty object as their value.
54
- */
55
- const initializeGroupData = (
56
- formElements: FormElement[],
57
- data: Record<string, unknown>,
58
- ): Record<string, unknown> => {
59
- const result = { ...data }
60
-
61
- for (const element of formElements) {
62
- const elementRecord = element as Record<string, unknown>
63
- const formkitType = elementRecord.formkit || elementRecord.$formkit
64
-
65
- if (formkitType === 'group') {
66
- const name = elementRecord.name as string
67
- const children = elementRecord.children as FormElement[] | undefined
68
-
69
- if (result[name] === null || result[name] === undefined) {
70
- result[name] = {}
71
- }
72
-
73
- if (children && Array.isArray(children)) {
74
- result[name] = initializeGroupData(children, result[name] as Record<string, unknown>)
75
- }
76
- }
77
- }
78
-
79
- return result
80
- }
81
-
82
- const configurationData = computed(() => {
83
- const rawData = (agentInstance.value?.configuration || {}) as Record<string, unknown>
84
- return initializeGroupData(configForm.value, rawData)
85
- })
46
+ // Pass the saved configuration through unchanged. DynamicConfiguration hydrates it
47
+ // (seedNullableToggles then seedFormDefaults): non-nullable groups are materialised to
48
+ // objects, while nullable groups keep their saved `null` so their "Enable" toggle loads
49
+ // off. Pre-filling `null` groups with `{}` here would make every disabled nullable group
50
+ // (e.g. reranking_config, org_memory) load as enabled.
51
+ const configurationData = computed(
52
+ () => (agentInstance.value?.configuration || {}) as Record<string, unknown>,
53
+ )
86
54
 
87
55
  const submitConfiguration = async (formData: Record<string, unknown>) => {
88
56
  const agentClass = route.params.agent_class as string
@@ -59,7 +59,7 @@
59
59
 
60
60
  <script setup lang="ts">
61
61
  import { useDebounceFn } from '@vueuse/core'
62
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
62
+ import { capitalCase } from 'change-case'
63
63
 
64
64
  import type { DocumentDto } from '@core/sdk/client'
65
65
 
@@ -114,11 +114,11 @@ const currentNamespace = computed(() => {
114
114
  })
115
115
 
116
116
  const databaseDisplayName = computed(() => {
117
- return currentDatabase.value?.display_name || useChangeCase(route.params.db as string, 'capitalCase').value
117
+ return currentDatabase.value?.display_name || capitalCase(route.params.db as string)
118
118
  })
119
119
 
120
120
  const namespaceDisplayName = computed(() => {
121
- return currentNamespace.value?.display_name || useChangeCase(route.params.namespace as string, 'capitalCase').value
121
+ return currentNamespace.value?.display_name || capitalCase(route.params.namespace as string)
122
122
  })
123
123
 
124
124
  const toDocument = (document: DocumentDto) => {
@@ -10,7 +10,7 @@
10
10
  :key="database.name"
11
11
  >
12
12
  <div class="flex items-center gap-2 pb-2 pl-2">
13
- <span class="text-sm font-medium">{{ database.display_name || useChangeCase(database.name, 'capitalCase') }}</span>
13
+ <span class="text-sm font-medium">{{ database.display_name || capitalCase(database.name) }}</span>
14
14
  <i
15
15
  v-if="database.auto_sync"
16
16
  class="pi pi-lock text-surface-400 dark:text-surface-500"
@@ -67,7 +67,7 @@
67
67
  </template>
68
68
 
69
69
  <script setup lang="ts">
70
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
70
+ import { capitalCase } from 'change-case'
71
71
 
72
72
  import type { DatabaseDto, NamespaceDto } from '@core/sdk/client'
73
73
 
@@ -96,8 +96,8 @@ const toNamespace = (database_name: string, namespace: NamespaceDto) => {
96
96
  const openUploadModal = (database: DatabaseDto, namespace: NamespaceDto) => {
97
97
  selectedDatabaseForUpload.value = database.name
98
98
  selectedNamespaceForUpload.value = namespace.name
99
- selectedDatabaseDisplayNameForUpload.value = database.display_name || useChangeCase(database.name, 'capitalCase')
100
- selectedNamespaceDisplayNameForUpload.value = namespace.display_name || useChangeCase(namespace.name, 'capitalCase')
99
+ selectedDatabaseDisplayNameForUpload.value = database.display_name || capitalCase(database.name)
100
+ selectedNamespaceDisplayNameForUpload.value = namespace.display_name || capitalCase(namespace.name)
101
101
  uploadModalVisible.value = true
102
102
  }
103
103
 
@@ -24,7 +24,7 @@
24
24
  <div
25
25
  class="pb-2 pl-2 text-sm font-medium"
26
26
  >
27
- {{ useChangeCase(modelType.name, 'capitalCase') }}
27
+ {{ capitalCase(modelType.name) }}
28
28
  </div>
29
29
  <div
30
30
  class="grid grid-cols-2 gap-4 2xl:grid-cols-2"
@@ -45,7 +45,7 @@
45
45
  </template>
46
46
 
47
47
  <script setup lang="ts">
48
- import { useChangeCase } from '@vueuse/integrations/useChangeCase'
48
+ import { capitalCase } from 'change-case'
49
49
 
50
50
  import type { ModelDTO } from '@core/sdk/client'
51
51