feat(fullstack): Add test application form creation

This commit is contained in:
2026-01-31 08:58:35 +01:00
parent 954c6d00e1
commit b279e6cc17
8 changed files with 326 additions and 14 deletions

View File

@@ -63,6 +63,31 @@ paths:
"503": "503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable" $ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
/test-data/application-form:
post:
summary: Creates a test application form based on seeded data
operationId: createTestDataApplicationForm
tags:
- test-data
parameters:
- in: query
name: organizationId
required: true
schema:
type: string
description: The organization ID to create the test form in
responses:
"201":
description: Successfully created test application form
content:
application/json:
schema:
$ref: "#/components/schemas/ApplicationFormDto"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
/application-forms/{id}: /application-forms/{id}:
parameters: parameters:
- name: id - name: id

View File

@@ -0,0 +1,107 @@
package com.betriebsratkanzlei.legalconsenthub.test_data
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationForm
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSectionDto
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import org.slf4j.LoggerFactory
import org.springframework.core.io.ClassPathResource
import org.springframework.stereotype.Service
@Service
class TestDataApplicationFormService(
private val applicationFormService: ApplicationFormService,
) {
private val logger = LoggerFactory.getLogger(TestDataApplicationFormService::class.java)
private val yamlMapper = ObjectMapper(YAMLFactory()).findAndRegisterModules()
fun createTestDataApplicationForm(organizationId: String): ApplicationForm {
val seededDto = loadInitialFormDto()
// Load template sections from seed template YAML (deterministic, no DB dependency)
val templateDto = loadInitialFormTemplateDto()
val templateSections =
templateDto.formElementSections.filter { it.isTemplate == true && !it.templateReference.isNullOrBlank() }
// Merge seeded sections with template sections
// Add template sections that don't already exist as templates in the seeded form
// (templates are needed for reactive section spawning even if spawned instances exist)
val finalSections =
seededDto.formElementSections +
templateSections.filter { ts ->
seededDto.formElementSections.none { seeded ->
seeded.isTemplate == true && seeded.templateReference == ts.templateReference
}
}
val newFormDto =
seededDto.copy(
id = null,
organizationId = organizationId,
status = ApplicationFormStatus.DRAFT,
name = "SAP S/4HANA (Copy ${System.currentTimeMillis()})",
isTemplate = false,
formElementSections = finalSections,
)
validateSectionSpawningInvariants(newFormDto.formElementSections)
return applicationFormService.createApplicationForm(newFormDto)
}
private fun loadInitialFormDto(): ApplicationFormDto =
ClassPathResource(INITIAL_FORM_RESOURCE_PATH).inputStream.use { inputStream ->
yamlMapper.readValue(inputStream, ApplicationFormDto::class.java)
}
private fun loadInitialFormTemplateDto(): ApplicationFormDto =
ClassPathResource(INITIAL_FORM_TEMPLATE_RESOURCE_PATH).inputStream.use { inputStream ->
yamlMapper.readValue(inputStream, ApplicationFormDto::class.java)
}
private fun validateSectionSpawningInvariants(sections: List<FormElementSectionDto>) {
val templateRefs = sections.filter { it.isTemplate == true }.mapNotNull { it.templateReference }.toSet()
val spawnedWithoutTemplateRef =
sections
.filter {
it.isTemplate != true &&
!it.spawnedFromElementReference.isNullOrBlank() &&
it.templateReference.isNullOrBlank()
}.mapNotNull { it.title }
if (spawnedWithoutTemplateRef.isNotEmpty()) {
val msg =
"Invalid seeded form: spawned sections missing templateReference: " +
spawnedWithoutTemplateRef.joinToString(", ")
logger.error(msg)
throw IllegalStateException(msg)
}
val triggerTemplateRefs =
sections
.flatMap { it.formElementSubSections }
.flatMap { it.formElements }
.flatMap { it.sectionSpawnTriggers ?: emptyList() }
.map { it.templateReference }
.filter { it.isNotBlank() }
.toSet()
val missingTemplateSections = triggerTemplateRefs.minus(templateRefs)
if (missingTemplateSections.isNotEmpty()) {
val msg =
"Invalid seeded form: missing template sections for triggers: " +
missingTemplateSections.joinToString(", ")
logger.error(msg)
throw IllegalStateException(msg)
}
}
private companion object {
private const val INITIAL_FORM_RESOURCE_PATH = "seed/initial_application_form.yaml"
private const val INITIAL_FORM_TEMPLATE_RESOURCE_PATH = "seed/initial_application_form_template.yaml"
}
}

View File

@@ -0,0 +1,24 @@
package com.betriebsratkanzlei.legalconsenthub.test_data
import com.betriebsratkanzlei.legalconsenthub.application_form.ApplicationFormMapper
import com.betriebsratkanzlei.legalconsenthub_api.api.TestDataApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.RestController
@RestController
class TestDataController(
private val testDataApplicationFormService: TestDataApplicationFormService,
private val applicationFormMapper: ApplicationFormMapper,
) : TestDataApi {
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun createTestDataApplicationForm(organizationId: String): ResponseEntity<ApplicationFormDto> =
ResponseEntity.status(201).body(
applicationFormMapper.toApplicationFormDto(
testDataApplicationFormService.createTestDataApplicationForm(organizationId),
),
)
}

View File

@@ -403,7 +403,7 @@ formElementSections:
formElementExpectedValue: Einführung formElementExpectedValue: Einführung
formElementOperator: EQUALS formElementOperator: EQUALS
sectionSpawnTriggers: sectionSpawnTriggers:
- templateReference: ki_details_template - templateReference: ki_informationen_template
sectionSpawnConditionType: SHOW sectionSpawnConditionType: SHOW
sectionSpawnExpectedValue: Ja sectionSpawnExpectedValue: Ja
sectionSpawnOperator: EQUALS sectionSpawnOperator: EQUALS
@@ -444,6 +444,8 @@ formElementSections:
- title: Rollen und Berechtigungen - title: Rollen und Berechtigungen
shortTitle: Rollen/Berechtigungen shortTitle: Rollen/Berechtigungen
description: Vollständiges Rollen- und Berechtigungskonzept für das IT-System description: Vollständiges Rollen- und Berechtigungskonzept für das IT-System
templateReference: rollen_berechtigungen_template
titleTemplate: Rollen und Berechtigungen
spawnedFromElementReference: art_der_massnahme spawnedFromElementReference: art_der_massnahme
formElementSubSections: formElementSubSections:
@@ -642,6 +644,8 @@ formElementSections:
- title: Verarbeitung von Mitarbeiterdaten - title: Verarbeitung von Mitarbeiterdaten
shortTitle: Mitarbeiterdaten shortTitle: Mitarbeiterdaten
description: Angaben zur Verarbeitung von personenbezogenen Arbeitnehmerdaten description: Angaben zur Verarbeitung von personenbezogenen Arbeitnehmerdaten
templateReference: verarbeitung_mitarbeiterdaten_template
titleTemplate: Verarbeitung von Mitarbeiterdaten
spawnedFromElementReference: art_der_massnahme spawnedFromElementReference: art_der_massnahme
formElementSubSections: formElementSubSections:
@@ -727,7 +731,7 @@ formElementSections:
label: Export/Weitergabe (Ja/Nein + Ziel) label: Export/Weitergabe (Ja/Nein + Ziel)
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
- value: '["false", "true", "false", "true", "true"]' - value: '[false, true, false, true, true]'
label: Leistungs-/Verhaltenskontrolle beabsichtigt? label: Leistungs-/Verhaltenskontrolle beabsichtigt?
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: SENSITIVE employeeDataCategory: SENSITIVE
@@ -805,7 +809,7 @@ formElementSections:
label: Schutzmaßnahmen/Governance label: Schutzmaßnahmen/Governance
processingPurpose: SYSTEM_OPERATION processingPurpose: SYSTEM_OPERATION
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
- value: '["true", "true", "true"]' - value: '[true, true, true]'
label: Audit-Logging erforderlich label: Audit-Logging erforderlich
processingPurpose: SYSTEM_OPERATION processingPurpose: SYSTEM_OPERATION
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
@@ -843,7 +847,7 @@ formElementSections:
columnConfig: columnConfig:
sourceTableReference: rollenstamm_tabelle sourceTableReference: rollenstamm_tabelle
sourceColumnIndex: 0 sourceColumnIndex: 0
- value: '["true", "true", "true", "true", "true"]' - value: '[true, true, true, true, true]'
label: Sichtbar (Ja/Nein) label: Sichtbar (Ja/Nein)
processingPurpose: SYSTEM_OPERATION processingPurpose: SYSTEM_OPERATION
employeeDataCategory: NON_CRITICAL employeeDataCategory: NON_CRITICAL
@@ -918,6 +922,8 @@ formElementSections:
- title: Löschkonzept - title: Löschkonzept
shortTitle: Löschkonzept shortTitle: Löschkonzept
description: Angaben zum Löschkonzept für Verarbeitungsvorgänge, Datenkategorien und Arbeitnehmerdaten description: Angaben zum Löschkonzept für Verarbeitungsvorgänge, Datenkategorien und Arbeitnehmerdaten
templateReference: loeschkonzept_template
titleTemplate: Löschkonzept
spawnedFromElementReference: art_der_massnahme spawnedFromElementReference: art_der_massnahme
formElementSubSections: formElementSubSections:
@@ -1080,6 +1086,8 @@ formElementSections:
- title: Schnittstellen - title: Schnittstellen
shortTitle: Schnittstellen shortTitle: Schnittstellen
description: Angaben zu Schnittstellen zwischen IT-Systemen description: Angaben zu Schnittstellen zwischen IT-Systemen
templateReference: schnittstellen_template
titleTemplate: Schnittstellen
spawnedFromElementReference: art_der_massnahme spawnedFromElementReference: art_der_massnahme
formElementSubSections: formElementSubSections:
@@ -1152,6 +1160,8 @@ formElementSections:
- title: Datenschutz - title: Datenschutz
shortTitle: Datenschutz shortTitle: Datenschutz
description: Datenschutzrechtliche Angaben zur Datenverarbeitung description: Datenschutzrechtliche Angaben zur Datenverarbeitung
templateReference: datenschutz_template
titleTemplate: Datenschutz
spawnedFromElementReference: art_der_massnahme spawnedFromElementReference: art_der_massnahme
formElementSubSections: formElementSubSections:
@@ -1488,6 +1498,8 @@ formElementSections:
- title: Modul SAP Finance and Controlling (FI/CO) - title: Modul SAP Finance and Controlling (FI/CO)
shortTitle: SAP Finance and Controlling (FI/CO) shortTitle: SAP Finance and Controlling (FI/CO)
description: Detaillierte Informationen zum Modul description: Detaillierte Informationen zum Modul
templateReference: module_details_template
titleTemplate: 'Modul: {{triggerValue}}'
spawnedFromElementReference: modul_1 spawnedFromElementReference: modul_1
formElementSubSections: formElementSubSections:
@@ -1532,7 +1544,7 @@ formElementSections:
label: Analytische Funktionen label: Analytische Funktionen
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
- value: '["true", "true", "true", "false"]' - value: '[true, true, true, false]'
label: In Nutzung label: In Nutzung
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
@@ -1626,6 +1638,8 @@ formElementSections:
- title: Modul SAP Human Capital Management (HCM) - title: Modul SAP Human Capital Management (HCM)
shortTitle: SAP Human Capital Management (HCM) shortTitle: SAP Human Capital Management (HCM)
description: Detaillierte Informationen zum Modul description: Detaillierte Informationen zum Modul
templateReference: module_details_template
titleTemplate: 'Modul: {{triggerValue}}'
spawnedFromElementReference: modul_2 spawnedFromElementReference: modul_2
formElementSubSections: formElementSubSections:
@@ -1670,7 +1684,7 @@ formElementSections:
label: Analytische Funktionen label: Analytische Funktionen
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
- value: '["true", "true", "true", "true", "true", "false"]' - value: '[true, true, true, true, true, false]'
label: In Nutzung label: In Nutzung
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
@@ -1764,6 +1778,8 @@ formElementSections:
- title: Modul SAP Supply Chain Management (SCM) - title: Modul SAP Supply Chain Management (SCM)
shortTitle: SAP Supply Chain Management (SCM) shortTitle: SAP Supply Chain Management (SCM)
description: Detaillierte Informationen zum Modul description: Detaillierte Informationen zum Modul
templateReference: module_details_template
titleTemplate: 'Modul: {{triggerValue}}'
spawnedFromElementReference: modul_3 spawnedFromElementReference: modul_3
formElementSubSections: formElementSubSections:
@@ -1808,7 +1824,7 @@ formElementSections:
label: Analytische Funktionen label: Analytische Funktionen
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
- value: '["true", "true", "true", "false", "true"]' - value: '[true, true, true, false, true]'
label: In Nutzung label: In Nutzung
processingPurpose: DATA_ANALYSIS processingPurpose: DATA_ANALYSIS
employeeDataCategory: REVIEW_REQUIRED employeeDataCategory: REVIEW_REQUIRED
@@ -1902,6 +1918,8 @@ formElementSections:
- title: Auswirkungen auf Arbeitnehmer - title: Auswirkungen auf Arbeitnehmer
shortTitle: Auswirkungen auf AN shortTitle: Auswirkungen auf AN
description: Auswirkungen des IT-Systems auf Arbeitnehmer, Arbeitsabläufe und Arbeitsbedingungen description: Auswirkungen des IT-Systems auf Arbeitnehmer, Arbeitsabläufe und Arbeitsbedingungen
templateReference: auswirkungen_arbeitnehmer_template
titleTemplate: Auswirkungen auf Arbeitnehmer
spawnedFromElementReference: art_der_massnahme spawnedFromElementReference: art_der_massnahme
formElementSubSections: formElementSubSections:
@@ -2187,6 +2205,9 @@ formElementSections:
- title: Informationen zur Künstlichen Intelligenz - title: Informationen zur Künstlichen Intelligenz
shortTitle: KI-Informationen shortTitle: KI-Informationen
description: Detaillierte Angaben zum Einsatz von Künstlicher Intelligenz gemäß EU-KI-Verordnung description: Detaillierte Angaben zum Einsatz von Künstlicher Intelligenz gemäß EU-KI-Verordnung
templateReference: ki_informationen_template
titleTemplate: Informationen zur Künstlichen Intelligenz
spawnedFromElementReference: ki_einsatz
formElementSubSections: formElementSubSections:
# Risk class selection # Risk class selection

View File

@@ -203,9 +203,10 @@ const { isSwiping } = usePointerSwipe(stepperScrollEl, {
const previousVisibilityMap = ref<Map<string, boolean>>(new Map()) const previousVisibilityMap = ref<Map<string, boolean>>(new Map())
const allFormElements = computed(() => { const allFormElements = computed(() => {
return props.formElementSections const nonTemplateSections = props.formElementSections.filter((section) => section.isTemplate !== true)
.filter((section) => section.isTemplate !== true) return nonTemplateSections.flatMap(
.flatMap((section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? []) (section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? []
)
}) })
const visibilityMap = computed(() => { const visibilityMap = computed(() => {
@@ -407,15 +408,34 @@ function handleFormElementUpdate(updatedFormElements: FormElementDto[], subsecti
} }
function clearNewlyHiddenFormElements(sections: FormElementSectionDto[]): FormElementSectionDto[] { function clearNewlyHiddenFormElements(sections: FormElementSectionDto[]): FormElementSectionDto[] {
const allElements = sections.flatMap( // Only evaluate visibility for non-template sections to avoid duplicate reference issues
const nonTemplateSections = sections.filter((section) => section.isTemplate !== true)
const allElements = nonTemplateSections.flatMap(
(section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? [] (section) => section.formElementSubSections?.flatMap((subsection) => subsection.formElements) ?? []
) )
const newVisibilityMap = evaluateFormElementVisibility(allElements) const newVisibilityMap = evaluateFormElementVisibility(allElements)
const clearedSections = clearHiddenFormElementValues(sections, previousVisibilityMap.value, newVisibilityMap) // Only clear values in non-template sections, preserve template sections unchanged
const clearedNonTemplateSections = clearHiddenFormElementValues(
nonTemplateSections,
previousVisibilityMap.value,
newVisibilityMap
)
previousVisibilityMap.value = newVisibilityMap previousVisibilityMap.value = newVisibilityMap
return clearedSections // Create a map of cleared sections by their identity for lookup
const clearedSectionMap = new Map<FormElementSectionDto, FormElementSectionDto>()
nonTemplateSections.forEach((original, index) => {
clearedSectionMap.set(original, clearedNonTemplateSections[index]!)
})
// Preserve original order: replace non-template sections with cleared versions, keep templates unchanged
return sections.map((section) => {
if (section.isTemplate === true) {
return section
}
return clearedSectionMap.get(section) ?? section
})
} }
function getSubsectionKey( function getSubsectionKey(

View File

@@ -0,0 +1,73 @@
import { useUserStore } from '~~/stores/useUserStore'
import { useTestDataApi } from '~/composables/testing/useTestDataApi'
/**
* TESTING-ONLY FEATURE
*
* One-click duplicator for the seeded demo application form "SAP S/4HANA".
*
* This version uses a dedicated backend endpoint to ensure reliability:
* 1. Loads the seeded YAML on the backend.
* 2. Creates a fresh ApplicationForm entity with new IDs.
* 3. Sets current user as creator.
* 4. Assigns the form to the currently selected organization.
*/
export function useSeededSapS4HanaDuplicator() {
const { t } = useI18n()
const logger = useLogger().withTag('seeded-sap-duplicator')
const toast = useToast()
const { canWriteApplicationForms } = usePermissions()
const userStore = useUserStore()
const { selectedOrganization } = storeToRefs(userStore)
const isDuplicating = ref(false)
const showButton = computed(() => canWriteApplicationForms.value)
async function duplicateSapS4HanaForTesting() {
if (isDuplicating.value) return
const organizationId = selectedOrganization.value?.id
if (!organizationId) {
toast.add({
title: t('common.error'),
description: 'Please select an organization first.',
color: 'error'
})
return
}
isDuplicating.value = true
try {
const { createTestDataApplicationForm } = useTestDataApi()
const created = await createTestDataApplicationForm(organizationId)
toast.add({
title: t('common.success'),
description: 'Created a new test application form.',
color: 'success'
})
if (created?.id) {
await navigateTo(`/application-forms/${created.id}/0`)
}
} catch (e: unknown) {
logger.error('Failed creating test application form via backend:', e)
toast.add({
title: t('common.error'),
description: 'Failed to create test application form. Check backend logs.',
color: 'error'
})
} finally {
isDuplicating.value = false
}
}
return {
showButton,
isDuplicating,
duplicateSapS4HanaForTesting
}
}

View File

@@ -0,0 +1,24 @@
import { TestDataApi, Configuration, type ApplicationFormDto } from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useTestDataApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const testDataApiClient = new TestDataApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function createTestDataApplicationForm(organizationId: string): Promise<ApplicationFormDto> {
return testDataApiClient.createTestDataApplicationForm({ organizationId })
}
return {
createTestDataApplicationForm
}
}

View File

@@ -25,7 +25,18 @@
<template #footer="{ collapsed }"> <template #footer="{ collapsed }">
<UserMenu :collapsed="collapsed" /> <UserMenu :collapsed="collapsed" />
<UButton @click="copyAccessTokenToClipboard">📋</UButton> <UButton
v-if="showSapCopyButton"
:loading="isDuplicatingSapForm"
:disabled="isDuplicatingSapForm"
icon="i-lucide-copy"
size="xs"
variant="soft"
@click="duplicateSapS4HanaForTesting"
>
🖨
</UButton>
<UButton size="xs" variant="soft" @click="copyAccessTokenToClipboard">📋</UButton>
</template> </template>
</UDashboardSidebar> </UDashboardSidebar>
@@ -37,6 +48,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useNotificationStore } from '~~/stores/useNotificationStore' import { useNotificationStore } from '~~/stores/useNotificationStore'
import { useSeededSapS4HanaDuplicator } from '~/composables/testing/useSeededSapS4HanaDuplicator'
const { t: $t } = useI18n() const { t: $t } = useI18n()
@@ -65,6 +77,12 @@ const isNotificationsSlideoverOpen = ref(false)
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
const { hasUnread } = storeToRefs(notificationStore) const { hasUnread } = storeToRefs(notificationStore)
const {
showButton: showSapCopyButton,
isDuplicating: isDuplicatingSapForm,
duplicateSapS4HanaForTesting
} = useSeededSapS4HanaDuplicator()
onMounted(async () => { onMounted(async () => {
await notificationStore.fetchUnreadCount() await notificationStore.fetchUnreadCount()
notificationStore.startPeriodicRefresh() notificationStore.startPeriodicRefresh()