feat(frontend): Add useFormElementDuplication test

This commit is contained in:
2026-03-02 10:46:26 +01:00
parent 7d18824c32
commit 803316c4cc
5 changed files with 394 additions and 7 deletions

View File

@@ -198,7 +198,7 @@ const hasOverflow = ref(false)
const { evaluateFormElementVisibility } = useFormElementVisibility()
const { clearHiddenFormElementValues } = useFormElementValueClearing()
const { processSpawnTriggers } = useSectionSpawning()
const { cloneElement } = useClonableElements()
const { cloneElement } = useFormElementDuplication()
const { isSwiping } = usePointerSwipe(stepperScrollEl, {
threshold: 0,

View File

@@ -1,11 +1,13 @@
import type { FormElementDto } from '~~/.api-client'
export function useClonableElements() {
function cloneElement(element: FormElementDto, existingElements: FormElementDto[]): FormElementDto {
const newReference = element.reference ? generateNextReference(existingElements, element.reference) : undefined
const isTextField = element.type === 'TEXTAREA' || element.type === 'TEXTFIELD'
export function useFormElementDuplication() {
function cloneElement(elementToClone: FormElementDto, existingElements: FormElementDto[]): FormElementDto {
const newReference = elementToClone.reference
? generateNextReference(existingElements, elementToClone.reference)
: undefined
const isTextField = elementToClone.type === 'TEXTAREA' || elementToClone.type === 'TEXTFIELD'
const clonedElement = JSON.parse(JSON.stringify(element)) as FormElementDto
const clonedElement = JSON.parse(JSON.stringify(elementToClone)) as FormElementDto
const resetOptions = clonedElement.options.map((option) => ({
...option,
value: isTextField ? '' : option.value

View File

@@ -38,12 +38,13 @@
"@nuxt/eslint": "1.1.0",
"@nuxt/test-utils": "^3.21.0",
"@openapitools/openapi-generator-cli": "2.16.3",
"@pinia/testing": "^0.1.7",
"@vitest/coverage-v8": "4.0.16",
"@vue/test-utils": "^2.4.6",
"eslint": "9.20.1",
"happy-dom": "^20.0.11",
"prettier": "3.5.1",
"typescript": "5.7.3",
"@pinia/testing": "^0.1.7",
"vitest": "^4.0.16",
"vue-tsc": "2.2.2"
},

View File

@@ -63,6 +63,9 @@ importers:
'@pinia/testing':
specifier: ^0.1.7
version: 0.1.7(pinia@3.0.3(typescript@5.7.3)(vue@3.5.26(typescript@5.7.3)))(vue@3.5.26(typescript@5.7.3))
'@vitest/coverage-v8':
specifier: 4.0.16
version: 4.0.16(vitest@4.0.16(@types/node@20.19.27)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
@@ -231,6 +234,10 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@bomb.sh/tab@0.0.10':
resolution: {integrity: sha512-6ALS2rh/4LKn0Yxwm35V6LcgQuSiECHbqQo7+9g4rkgGyXZ0siOc8K+IuWIq/4u0Zkv2mevP9QSqgKhGIvLJMw==}
hasBin: true
@@ -2370,6 +2377,15 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
vue: ^3.2.25
'@vitest/coverage-v8@4.0.16':
resolution: {integrity: sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==}
peerDependencies:
'@vitest/browser': 4.0.16
vitest: 4.0.16
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.0.16':
resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==}
@@ -2694,6 +2710,9 @@ packages:
resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
engines: {node: '>=4'}
ast-v8-to-istanbul@0.3.12:
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
ast-walker-scope@0.6.2:
resolution: {integrity: sha512-1UWOyC50xI3QZkRuDj6PqDtpm1oHWtYs+NQGwqL/2R11eN3Q81PHAHPM0SWW3BNQm53UDwS//Jv8L4CCVLM1bQ==}
engines: {node: '>=16.14.0'}
@@ -3804,6 +3823,9 @@ packages:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -3997,6 +4019,22 @@ packages:
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-lib-source-maps@5.0.6:
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
iterare@1.2.1:
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
engines: {node: '>=6'}
@@ -4020,6 +4058,9 @@ packages:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4265,6 +4306,10 @@ packages:
magicast@0.5.1:
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@@ -6348,6 +6393,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@bomb.sh/tab@0.0.10(cac@6.7.14)(citty@0.1.6)':
optionalDependencies:
cac: 6.7.14
@@ -8750,6 +8797,23 @@ snapshots:
vite: 7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)
vue: 3.5.26(typescript@5.7.3)
'@vitest/coverage-v8@4.0.16(vitest@4.0.16(@types/node@20.19.27)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.16
ast-v8-to-istanbul: 0.3.12
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magicast: 0.5.1
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.16(@types/node@20.19.27)(happy-dom@20.0.11)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@vitest/expect@4.0.16':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -9141,6 +9205,12 @@ snapshots:
dependencies:
tslib: 2.8.1
ast-v8-to-istanbul@0.3.12:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
ast-walker-scope@0.6.2:
dependencies:
'@babel/parser': 7.28.5
@@ -10363,6 +10433,8 @@ snapshots:
dependencies:
lru-cache: 10.4.3
html-escaper@2.0.2: {}
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
@@ -10552,6 +10624,27 @@ snapshots:
isomorphic.js@0.2.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
iterare@1.2.1: {}
jackspeak@3.4.3:
@@ -10574,6 +10667,8 @@ snapshots:
js-cookie@3.0.5: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -10803,6 +10898,10 @@ snapshots:
'@babel/types': 7.28.5
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.3
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1

View File

@@ -0,0 +1,285 @@
import { describe, it, expect } from 'vitest'
import { useFormElementDuplication } from '../../../app/composables/useFormElementDuplication'
import type { FormElementDto, FormOptionDto, FormElementType } from '../../../.api-client'
// Helper to create a FormOptionDto
function createOption(value: string, label: string): FormOptionDto {
return {
value,
label,
processingPurpose: 'NONE',
employeeDataCategory: 'NONE'
}
}
// Helper to create a FormElementDto
function createFormElement(
reference: string,
title: string,
type: FormElementType,
options: FormOptionDto[] = [],
description?: string
): FormElementDto {
return {
id: `id-${reference}`,
reference,
title,
type,
options,
description
}
}
describe('useFormElementDuplication', () => {
describe('cloneElement()', () => {
it('should generate incremented reference from existing reference with suffix', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('modul_1', 'Module', 'TEXTFIELD', [createOption('value', '')])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.reference).toBe('modul_2')
})
it('should handle reference without numeric suffix (defaults to _2)', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('modul', 'Module', 'TEXTFIELD', [createOption('value', '')])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.reference).toBe('modul_2')
})
it('should clear id and formElementSubSectionId on cloned element', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('elem_1', 'Element', 'TEXTFIELD', [createOption('value', '')])
elementToClone.id = 'some-uuid-id'
elementToClone.formElementSubSectionId = 'subsection-uuid'
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.id).toBeUndefined()
expect(cloned.formElementSubSectionId).toBeUndefined()
})
it('should deep clone element (modifying clone does not affect original)', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('elem_1', 'Element', 'SELECT', [
createOption('true', 'Yes'),
createOption('false', 'No')
])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
cloned.options[0]!.value = 'modified'
cloned.title = 'Modified Title'
expect(elementToClone.options[0]!.value).toBe('true')
expect(elementToClone.title).toBe('Element')
})
it('should handle multiple existing clones and generate correct next reference', () => {
const { cloneElement } = useFormElementDuplication()
const element1 = createFormElement('modul_1', 'Module', 'TEXTFIELD', [createOption('value1', '')])
const element2 = createFormElement('modul_2', 'Module', 'TEXTFIELD', [createOption('value2', '')])
const element3 = createFormElement('modul_3', 'Module', 'TEXTFIELD', [createOption('value3', '')])
const existingElements: FormElementDto[] = [element1, element2, element3]
const cloned = cloneElement(element1, existingElements)
expect(cloned.reference).toBe('modul_4')
})
it('should handle elements with no options', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('elem_1', 'Element', 'TEXTFIELD', [])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options).toEqual([])
expect(cloned.reference).toBe('elem_2')
})
it('should handle sparse numeric suffix correctly', () => {
const { cloneElement } = useFormElementDuplication()
const element1 = createFormElement('item_1', 'Item', 'TEXTFIELD', [createOption('v1', '')])
const element3 = createFormElement('item_3', 'Item', 'TEXTFIELD', [createOption('v3', '')])
const existingElements: FormElementDto[] = [element1, element3]
const cloned = cloneElement(element1, existingElements)
expect(cloned.reference).toBe('item_4')
})
it('should handle element with formElementSubSectionId undefined already', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('elem_1', 'Element', 'TEXTFIELD', [createOption('value', '')])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.formElementSubSectionId).toBeUndefined()
})
describe('option value handling by form element type', () => {
it('should reset TEXTAREA option values to empty string', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('textarea_1', 'My Title', 'TEXTAREA', [
createOption('Original text content', '')
])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('')
expect(cloned.reference).toBe('textarea_2')
})
it('should reset TEXTFIELD option values to empty string', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('textfield_1', 'My Title', 'TEXTFIELD', [
createOption('Original text content', '')
])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('')
expect(cloned.reference).toBe('textfield_2')
})
it('should preserve SELECT option values', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('select_1', 'Choice', 'SELECT', [
createOption('false', 'Option A'),
createOption('true', 'Option B')
])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('false')
expect(cloned.options[1]!.value).toBe('true')
expect(cloned.reference).toBe('select_2')
})
it('should preserve CHECKBOX option values', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('checkbox_1', 'Features', 'CHECKBOX', [
createOption('true', 'Feature A'),
createOption('false', 'Feature B')
])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('true')
expect(cloned.options[1]!.value).toBe('false')
expect(cloned.reference).toBe('checkbox_2')
})
it('should preserve RADIOBUTTON option values', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('radio_1', 'Gender', 'RADIOBUTTON', [
createOption('true', 'Male'),
createOption('false', 'Female')
])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('true')
expect(cloned.options[1]!.value).toBe('false')
expect(cloned.reference).toBe('radio_2')
})
it('should handle SWITCH element option values', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('switch_1', 'Enable', 'SWITCH', [createOption('true', 'Enabled')])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('true')
expect(cloned.reference).toBe('switch_2')
})
it('should handle DATE element option values', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('date_1', 'Start Date', 'DATE', [createOption('2024-01-15', 'label')])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('2024-01-15')
expect(cloned.reference).toBe('date_2')
})
it('should handle RICH_TEXT element option values', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('richtext_1', 'Notes', 'RICH_TEXT', [
createOption('<p>Content</p>', '')
])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('<p>Content</p>')
expect(cloned.reference).toBe('richtext_2')
})
it('should handle TABLE element option values', () => {
const { cloneElement } = useFormElementDuplication()
const elementToClone = createFormElement('table_1', 'Employees', 'TABLE', [
createOption('["row1", "row2"]', 'Name'),
createOption('["dev", "designer"]', 'Role')
])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.value).toBe('["row1", "row2"]')
expect(cloned.options[1]!.value).toBe('["dev", "designer"]')
expect(cloned.reference).toBe('table_2')
})
it('should preserve all FormOptionDto properties when cloning', () => {
const { cloneElement } = useFormElementDuplication()
const option = createOption('true', 'Yes')
option.processingPurpose = 'BUSINESS_PROCESS'
option.employeeDataCategory = 'SENSITIVE'
const elementToClone = createFormElement('elem_1', 'Element', 'SELECT', [option])
const existingElements: FormElementDto[] = [elementToClone]
const cloned = cloneElement(elementToClone, existingElements)
expect(cloned.options[0]!.processingPurpose).toBe('BUSINESS_PROCESS')
expect(cloned.options[0]!.employeeDataCategory).toBe('SENSITIVE')
})
})
})
})