diff --git a/legalconsenthub/app/components/FormStepperWithNavigation.vue b/legalconsenthub/app/components/FormStepperWithNavigation.vue index 260bc80..2f16f7c 100644 --- a/legalconsenthub/app/components/FormStepperWithNavigation.vue +++ b/legalconsenthub/app/components/FormStepperWithNavigation.vue @@ -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, diff --git a/legalconsenthub/app/composables/useClonableElements.ts b/legalconsenthub/app/composables/useFormElementDuplication.ts similarity index 71% rename from legalconsenthub/app/composables/useClonableElements.ts rename to legalconsenthub/app/composables/useFormElementDuplication.ts index e510c0a..cd02f8b 100644 --- a/legalconsenthub/app/composables/useClonableElements.ts +++ b/legalconsenthub/app/composables/useFormElementDuplication.ts @@ -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 diff --git a/legalconsenthub/package.json b/legalconsenthub/package.json index 0badd44..f3af84d 100644 --- a/legalconsenthub/package.json +++ b/legalconsenthub/package.json @@ -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" }, diff --git a/legalconsenthub/pnpm-lock.yaml b/legalconsenthub/pnpm-lock.yaml index b3a234e..37f2be5 100644 --- a/legalconsenthub/pnpm-lock.yaml +++ b/legalconsenthub/pnpm-lock.yaml @@ -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 diff --git a/legalconsenthub/test/unit/composables/useFormElementDuplication.spec.ts b/legalconsenthub/test/unit/composables/useFormElementDuplication.spec.ts new file mode 100644 index 0000000..74b7a68 --- /dev/null +++ b/legalconsenthub/test/unit/composables/useFormElementDuplication.spec.ts @@ -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('

Content

', '') + ]) + const existingElements: FormElementDto[] = [elementToClone] + + const cloned = cloneElement(elementToClone, existingElements) + + expect(cloned.options[0]!.value).toBe('

Content

') + 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') + }) + }) + }) +})