Skip to content

Commit 06334d8

Browse files
committed
fix(cloud): harden survey validity and other-answer handling
- Stop auto-advancing on a cleared single-select and re-clamp stepIndex so a backend showWhen that hides an earlier field can't strand an empty step - Detect "other" for multi-select too across validity, schema, and payload, and reject whitespace-only free-text
1 parent f3e5edb commit 06334d8

3 files changed

Lines changed: 114 additions & 13 deletions

File tree

src/platform/cloud/onboarding/survey/DynamicSurveyForm.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import {
112112
buildSubmissionPayload,
113113
buildZodSchema,
114114
hasNonEmptyValue,
115+
isOtherValue,
115116
prepareSurvey,
116117
visibleFields
117118
} from './surveySchema'
@@ -216,24 +217,31 @@ const isCurrentValid = computed(() => {
216217
const value = values[field.id]
217218
if (!hasNonEmptyValue(value)) return !field.required
218219
219-
if (field.allowOther && field.otherFieldId && value === 'other') {
220+
if (field.allowOther && field.otherFieldId && isOtherValue(value)) {
220221
const other = values[field.otherFieldId]
221222
return typeof other === 'string' && other.trim().length > 0
222223
}
223224
return true
224225
})
225226
226227
const isAutoAdvanceValue = (field: OnboardingSurveyField, value: unknown) =>
227-
field.type === 'single' && typeof value === 'string' && value !== 'other'
228+
field.type === 'single' &&
229+
typeof value === 'string' &&
230+
value !== '' &&
231+
value !== 'other'
228232
229233
const markTouched = (id: string) => {
230234
touched.value = new Set(touched.value).add(id)
231235
}
232236
233237
const onFieldChange = async (id: string, value: string | string[]) => {
238+
if (isAdvancing.value) return
234239
markTouched(id)
235240
setFieldValue(id, value)
236241
liveValues.value = { ...liveValues.value, [id]: value }
242+
if (stepIndex.value > visible.value.length - 1) {
243+
stepIndex.value = Math.max(0, visible.value.length - 1)
244+
}
237245
238246
const field = currentField.value
239247
if (field?.id === id && isAutoAdvanceValue(field, value)) {

src/platform/cloud/onboarding/survey/surveySchema.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,84 @@ describe('hasNonEmptyValue', () => {
324324
expect(hasNonEmptyValue(value)).toBe(expected)
325325
})
326326
})
327+
328+
describe('multi-select allowOther', () => {
329+
const multiOtherSurvey: OnboardingSurvey = {
330+
version: 1,
331+
fields: [
332+
{
333+
id: 'making',
334+
type: 'multi',
335+
required: true,
336+
allowOther: true,
337+
otherFieldId: 'makingOther',
338+
options: [
339+
{ value: 'a', labelKey: 'a' },
340+
{ value: 'other', labelKey: 'other' }
341+
]
342+
}
343+
]
344+
}
345+
346+
it('requires the free-text when a multi field includes "other"', () => {
347+
const schema = buildZodSchema(multiOtherSurvey, {
348+
making: ['a', 'other'],
349+
makingOther: ''
350+
})
351+
expect(
352+
schema.safeParse({ making: ['a', 'other'], makingOther: '' }).success
353+
).toBe(false)
354+
expect(
355+
schema.safeParse({ making: ['a', 'other'], makingOther: 'Comics' })
356+
.success
357+
).toBe(true)
358+
})
359+
360+
it('does not require the free-text when "other" is not among the choices', () => {
361+
const schema = buildZodSchema(multiOtherSurvey, {
362+
making: ['a'],
363+
makingOther: ''
364+
})
365+
expect(schema.safeParse({ making: ['a'], makingOther: '' }).success).toBe(
366+
true
367+
)
368+
})
369+
370+
it('keeps the array and surfaces the trimmed free-text separately', () => {
371+
const payload = buildSubmissionPayload(multiOtherSurvey, {
372+
making: ['a', 'other'],
373+
makingOther: ' Comics '
374+
})
375+
expect(payload.making).toEqual(['a', 'other'])
376+
expect(payload.makingOther).toBe('Comics')
377+
})
378+
})
379+
380+
describe('other free-text validation', () => {
381+
const otherSurvey: OnboardingSurvey = {
382+
version: 1,
383+
fields: [
384+
{
385+
id: 'source',
386+
type: 'single',
387+
required: true,
388+
allowOther: true,
389+
otherFieldId: 'sourceOther',
390+
options: [
391+
{ value: 'search', labelKey: 'search' },
392+
{ value: 'other', labelKey: 'other' }
393+
]
394+
}
395+
]
396+
}
397+
398+
it('rejects a whitespace-only "other" answer', () => {
399+
const schema = buildZodSchema(otherSurvey, {
400+
source: 'other',
401+
sourceOther: ' '
402+
})
403+
expect(
404+
schema.safeParse({ source: 'other', sourceOther: ' ' }).success
405+
).toBe(false)
406+
})
407+
})

src/platform/cloud/onboarding/survey/surveySchema.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export const hasNonEmptyValue = (
1717
return true
1818
}
1919

20+
export const isOtherValue = (current: string | string[] | undefined): boolean =>
21+
Array.isArray(current) ? current.includes('other') : current === 'other'
22+
2023
const conditionMatches = (
2124
condition: OnboardingSurveyFieldCondition | undefined,
2225
values: SurveyValues
@@ -89,11 +92,14 @@ export const buildZodSchema = (
8992
if (
9093
field.allowOther &&
9194
field.otherFieldId &&
92-
values[field.id] === 'other'
95+
isOtherValue(values[field.id])
9396
) {
94-
shape[field.otherFieldId] = z.string().min(1, {
95-
message: t('cloudOnboarding.survey.errors.describeAnswer')
96-
})
97+
shape[field.otherFieldId] = z
98+
.string()
99+
.trim()
100+
.min(1, {
101+
message: t('cloudOnboarding.survey.errors.describeAnswer')
102+
})
97103
} else if (field.otherFieldId) {
98104
shape[field.otherFieldId] = z.string().optional()
99105
}
@@ -122,17 +128,23 @@ export const buildSubmissionPayload = (
122128
continue
123129
}
124130
const value = values[field.id]
125-
const otherRaw = field.otherFieldId ? values[field.otherFieldId] : undefined
126-
if (
131+
const otherFieldId = field.otherFieldId
132+
const otherRaw = otherFieldId ? values[otherFieldId] : undefined
133+
const otherText =
127134
field.allowOther &&
128-
field.otherFieldId &&
129-
value === 'other' &&
135+
otherFieldId &&
136+
isOtherValue(value) &&
130137
typeof otherRaw === 'string'
131-
) {
132-
const other = otherRaw.trim()
133-
payload[field.id] = other || 'other'
138+
? otherRaw.trim()
139+
: undefined
140+
141+
if (otherText !== undefined && field.type !== 'multi') {
142+
payload[field.id] = otherText || 'other'
134143
} else {
135144
payload[field.id] = field.type === 'multi' ? (value ?? []) : (value ?? '')
145+
if (otherText !== undefined && otherFieldId) {
146+
payload[otherFieldId] = otherText
147+
}
136148
}
137149
}
138150
return payload

0 commit comments

Comments
 (0)