diff --git a/CLAUDE.md b/CLAUDE.md index 96220cf..4eeed1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,12 +97,19 @@ Application Form └── SubSection (FormElementSubSection) ├── title, subtitle └── Form Elements (FormElement) + ├── id (UUID - generated by backend) + ├── reference (string - custom key like "art_der_massnahme") ├── type (SELECT, CHECKBOX, RADIOBUTTON, TEXTFIELD, SWITCH, TITLE_BODY_TEXTFIELDS) ├── title, description - └── options (FormOption[]) - ├── value, label - ├── processingPurpose - └── employeeDataCategory + ├── options (FormOption[]) + │ ├── value, label + │ ├── processingPurpose + │ └── employeeDataCategory + └── visibilityCondition (FormElementVisibilityCondition) + ├── conditionType (SHOW, HIDE) + ├── sourceFormElementReference (string - reference key of source element) + ├── expectedValue (string - value to compare against) + └── operator (EQUALS, NOT_EQUALS, IS_EMPTY, IS_NOT_EMPTY) ``` **Dynamic Addition**: Users can add new form elements to any subsection at runtime via the API endpoint: @@ -185,6 +192,83 @@ User roles in the system: Roles are managed via Keycloak and enforced using Spring Security's `@PreAuthorize` annotations. +### 10. Conditional Form Element Visibility + +Form elements can be conditionally shown or hidden based on the values of other form elements. This enables dynamic forms that adapt to user input. + +**Key Concepts**: + +- **Visibility Conditions**: Rules attached to form elements that determine when they should be visible +- **Source Element**: The form element whose value is being evaluated +- **Target Element**: The form element with visibility conditions (the one being shown/hidden) +- **Condition Types**: + - `SHOW`: Element is visible only if the condition is met + - `HIDE`: Element is hidden if the condition is met +- **Operators**: + - `EQUALS`: Source value exactly matches expected value (case-insensitive) + - `NOT_EQUALS`: Source value does not match expected value + - `IS_EMPTY`: Source value is empty string + - `IS_NOT_EMPTY`: Source value is not empty + +**Implementation Details**: + +- Visibility conditions reference other form elements by their **reference key** (custom string identifier) +- Reference keys are stable and meaningful (e.g., "art_der_massnahme", "testphase_findet_statt") +- Each form element can have one visibility condition (single object, not array) +- Hidden fields are automatically excluded from validation +- PDF/HTML exports only include currently visible fields based on form state +- Versioning system captures visibility conditions in snapshots +- Conditions check the **value** property of form options, not labels + +**Example Configuration**: + +```json +{ + "reference": "testphase_zeitraum", + "title": "Testphase Zeitraum", + "description": "Zeitraum der Testphase", + "type": "TEXTFIELD", + "options": [ + { + "value": "", + "label": "Testphase Zeitraum", + "processingPurpose": "SYSTEM_OPERATION", + "employeeDataCategory": "NON_CRITICAL" + } + ], + "visibilityCondition": { + "conditionType": "SHOW", + "sourceFormElementReference": "testphase_findet_statt", + "expectedValue": "Ja", + "operator": "EQUALS" + } +} +``` + +In this example, the "Testphase Zeitraum" field is only visible when the form element with reference `testphase_findet_statt` has the value "Ja" selected. + +**Frontend Behavior**: + +- Visibility is evaluated client-side using the `useFormElementVisibility` composable +- Form elements smoothly appear/disappear as conditions change +- Hidden elements are excluded from the form submission + +**Backend Behavior**: + +- Visibility is evaluated server-side when generating PDF/HTML exports +- Backend filters form elements before rendering to Thymeleaf templates +- Hidden elements are not included in exported documents + +**Best Practices**: + +- Avoid circular dependencies (A depends on B, B depends on A) +- Use meaningful reference keys (snake_case recommended, e.g., "art_der_massnahme", "testphase_findet_statt") +- Reference keys should be unique within a form +- Keep visibility logic simple for better user experience +- For radio buttons and selects, use the actual label text as the value +- For checkboxes, checked = "true", unchecked = "false" +- Test visibility conditions thoroughly before deployment + --- ## Project Structure diff --git a/api/legalconsenthub.yml b/api/legalconsenthub.yml index d062b91..bfcb11c 100644 --- a/api/legalconsenthub.yml +++ b/api/legalconsenthub.yml @@ -1372,6 +1372,9 @@ components: id: type: string format: uuid + reference: + type: string + description: Unique reference key for this form element (e.g., "art_der_massnahme") title: type: string description: @@ -1385,6 +1388,8 @@ components: formElementSubSectionId: type: string format: uuid + visibilityCondition: + $ref: "#/components/schemas/FormElementVisibilityCondition" FormElementSnapshotDto: type: object @@ -1392,6 +1397,8 @@ components: - type - options properties: + reference: + type: string title: type: string description: @@ -1402,6 +1409,8 @@ components: type: array items: $ref: "#/components/schemas/FormOptionDto" + visibilityCondition: + $ref: "#/components/schemas/FormElementVisibilityCondition" CreateFormElementDto: type: object @@ -1409,6 +1418,8 @@ components: - options - type properties: + reference: + type: string title: type: string description: @@ -1419,6 +1430,8 @@ components: $ref: "#/components/schemas/FormOptionDto" type: $ref: "#/components/schemas/FormElementType" + visibilityCondition: + $ref: "#/components/schemas/FormElementVisibilityCondition" FormOptionDto: type: object @@ -1447,6 +1460,39 @@ components: - SWITCH - TITLE_BODY_TEXTFIELDS + FormElementVisibilityCondition: + type: object + required: + - conditionType + - sourceFormElementReference + - expectedValue + properties: + conditionType: + $ref: "#/components/schemas/VisibilityConditionType" + sourceFormElementReference: + type: string + description: Reference key of the source form element to check + expectedValue: + type: string + description: Expected value to compare against the source element's value property + operator: + $ref: "#/components/schemas/VisibilityConditionOperator" + default: EQUALS + + VisibilityConditionType: + type: string + enum: + - SHOW + - HIDE + + VisibilityConditionOperator: + type: string + enum: + - EQUALS + - NOT_EQUALS + - IS_EMPTY + - IS_NOT_EMPTY + ####### UserDto ####### UserDto: type: object diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt index 688746f..180a2f8 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt @@ -1,10 +1,16 @@ package com.betriebsratkanzlei.legalconsenthub.application_form +import com.betriebsratkanzlei.legalconsenthub.form_element.FormElement +import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection +import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSubSection +import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionOperator +import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionType import com.openhtmltopdf.pdfboxout.PdfRendererBuilder import org.springframework.stereotype.Service import org.thymeleaf.TemplateEngine import org.thymeleaf.context.Context import java.io.ByteArrayOutputStream +import java.util.UUID @Service class ApplicationFormFormatService( @@ -24,10 +30,118 @@ class ApplicationFormFormatService( } fun generateHtml(applicationForm: ApplicationForm): String { + val filteredForm = filterVisibleElements(applicationForm) val context = Context().apply { - setVariable("applicationForm", applicationForm) + setVariable("applicationForm", filteredForm) } return templateEngine.process("application_form_template", context) } + + private fun filterVisibleElements(applicationForm: ApplicationForm): ApplicationForm { + val formElementsByRef = buildFormElementsMap(applicationForm) + val visibilityMap = evaluateVisibility(formElementsByRef) + + val filteredSections = + applicationForm.formElementSections.mapNotNull { section -> + val filteredSubSections = + section.formElementSubSections.mapNotNull { subsection -> + val filteredElements = + subsection.formElements.filter { element -> + visibilityMap[element.id] == true + } + if (filteredElements.isEmpty()) { + null + } else { + FormElementSubSection( + id = subsection.id, + title = subsection.title, + subtitle = subsection.subtitle, + formElements = filteredElements.toMutableList(), + formElementSection = null, + ) + } + } + if (filteredSubSections.isEmpty()) { + null + } else { + FormElementSection( + id = section.id, + title = section.title, + shortTitle = section.shortTitle, + description = section.description, + formElementSubSections = filteredSubSections.toMutableList(), + applicationForm = null, + ) + } + } + + return ApplicationForm( + id = applicationForm.id, + name = applicationForm.name, + status = applicationForm.status, + createdBy = applicationForm.createdBy, + lastModifiedBy = applicationForm.lastModifiedBy, + createdAt = applicationForm.createdAt, + modifiedAt = applicationForm.modifiedAt, + isTemplate = applicationForm.isTemplate, + organizationId = applicationForm.organizationId, + formElementSections = filteredSections.toMutableList(), + ) + } + + private fun buildFormElementsMap(applicationForm: ApplicationForm): Map { + val map = mutableMapOf() + applicationForm.formElementSections.forEach { section -> + section.formElementSubSections.forEach { subsection -> + subsection.formElements.forEach { element -> + element.reference?.let { map[it] = element } + } + } + } + return map + } + + private fun evaluateVisibility(formElementsByRef: Map): Map { + val visibilityMap = mutableMapOf() + + formElementsByRef.values.forEach { element -> + visibilityMap[element.id] = isElementVisible(element, formElementsByRef) + } + + return visibilityMap + } + + private fun isElementVisible( + element: FormElement, + formElementsByRef: Map, + ): Boolean { + val condition = element.visibilityCondition ?: return true + + // Don't show if source element is missing + val sourceElement = formElementsByRef[condition.sourceFormElementReference] ?: return false + + val sourceValue = getFormElementValue(sourceElement) + val conditionMet = evaluateCondition(sourceValue, condition.expectedValue, condition.operator) + + return when (condition.conditionType) { + VisibilityConditionType.SHOW -> conditionMet + VisibilityConditionType.HIDE -> !conditionMet + } + } + + private fun getFormElementValue(element: FormElement): String = + element.options.firstOrNull { it.value == "true" }?.label ?: "" + + private fun evaluateCondition( + actualValue: String, + expectedValue: String, + operator: VisibilityConditionOperator, + ): Boolean = + when (operator) { + VisibilityConditionOperator.EQUALS -> actualValue.equals(expectedValue, ignoreCase = true) + VisibilityConditionOperator.NOT_EQUALS -> !actualValue.equals(expectedValue, ignoreCase = true) + VisibilityConditionOperator.IS_EMPTY -> actualValue.isEmpty() + VisibilityConditionOperator.IS_NOT_EMPTY -> actualValue.isNotEmpty() + } } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElement.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElement.kt index 5091bfc..691ab88 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElement.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElement.kt @@ -16,6 +16,7 @@ class FormElement( @Id @GeneratedValue var id: UUID? = null, + var reference: String? = null, var title: String? = null, var description: String? = null, @ElementCollection @@ -26,4 +27,5 @@ class FormElement( @ManyToOne @JoinColumn(name = "form_element_sub_section_id", nullable = false) var formElementSubSection: FormElementSubSection? = null, + var visibilityCondition: FormElementVisibilityCondition? = null, ) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementMapper.kt index e58fcf9..82d9581 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementMapper.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementMapper.kt @@ -7,10 +7,12 @@ import org.springframework.stereotype.Component @Component class FormElementMapper( private val formOptionMapper: FormOptionMapper, + private val visibilityConditionMapper: FormElementVisibilityConditionMapper, ) { fun toFormElementDto(formElement: FormElement): FormElementDto = FormElementDto( id = formElement.id ?: throw IllegalStateException("FormElement ID must not be null!"), + reference = formElement.reference, title = formElement.title, description = formElement.description, options = formElement.options.map { formOptionMapper.toFormOptionDto(it) }, @@ -18,6 +20,10 @@ class FormElementMapper( formElementSubSectionId = formElement.formElementSubSection?.id ?: throw IllegalStateException("FormElementSubSection ID must not be null!"), + visibilityCondition = + formElement.visibilityCondition?.let { + visibilityConditionMapper.toFormElementVisibilityConditionDto(it) + }, ) fun toFormElement( @@ -26,11 +32,16 @@ class FormElementMapper( ): FormElement = FormElement( id = formElement.id, + reference = formElement.reference, title = formElement.title, description = formElement.description, options = formElement.options.map { formOptionMapper.toFormOption(it) }.toMutableList(), type = formElement.type, formElementSubSection = formElementSubSection, + visibilityCondition = + formElement.visibilityCondition?.let { + visibilityConditionMapper.toFormElementVisibilityCondition(it) + }, ) fun toFormElement( @@ -39,10 +50,15 @@ class FormElementMapper( ): FormElement = FormElement( id = null, + reference = formElement.reference, title = formElement.title, description = formElement.description, options = formElement.options.map { formOptionMapper.toFormOption(it) }.toMutableList(), type = formElement.type, formElementSubSection = formElementSubSection, + visibilityCondition = + formElement.visibilityCondition?.let { + visibilityConditionMapper.toFormElementVisibilityCondition(it) + }, ) } diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementVisibilityCondition.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementVisibilityCondition.kt new file mode 100644 index 0000000..40ee912 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementVisibilityCondition.kt @@ -0,0 +1,15 @@ +package com.betriebsratkanzlei.legalconsenthub.form_element + +import jakarta.persistence.Embeddable +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated + +@Embeddable +data class FormElementVisibilityCondition( + @Enumerated(EnumType.STRING) + val conditionType: VisibilityConditionType, + val sourceFormElementReference: String, + val expectedValue: String, + @Enumerated(EnumType.STRING) + val operator: VisibilityConditionOperator = VisibilityConditionOperator.EQUALS, +) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementVisibilityConditionMapper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementVisibilityConditionMapper.kt new file mode 100644 index 0000000..fb090ea --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormElementVisibilityConditionMapper.kt @@ -0,0 +1,71 @@ +package com.betriebsratkanzlei.legalconsenthub.form_element + +import org.springframework.stereotype.Component +import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementVisibilityCondition as FormElementVisibilityConditionDto +import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionOperator as VisibilityConditionOperatorDto +import com.betriebsratkanzlei.legalconsenthub_api.model.VisibilityConditionType as VisibilityConditionTypeDto + +@Component +class FormElementVisibilityConditionMapper { + fun toFormElementVisibilityConditionDto( + condition: FormElementVisibilityCondition, + ): FormElementVisibilityConditionDto = + FormElementVisibilityConditionDto( + conditionType = toVisibilityConditionTypeDto(condition.conditionType), + sourceFormElementReference = condition.sourceFormElementReference, + expectedValue = condition.expectedValue, + operator = toVisibilityConditionOperatorDto(condition.operator), + ) + + fun toFormElementVisibilityCondition( + conditionDto: FormElementVisibilityConditionDto, + ): FormElementVisibilityCondition = + FormElementVisibilityCondition( + conditionType = toVisibilityConditionType(conditionDto.conditionType), + sourceFormElementReference = conditionDto.sourceFormElementReference, + expectedValue = conditionDto.expectedValue, + operator = + conditionDto.operator?.let { toVisibilityConditionOperator(it) } + ?: VisibilityConditionOperator.EQUALS, + ) + + private fun toVisibilityConditionTypeDto(type: VisibilityConditionType): VisibilityConditionTypeDto = + when (type) { + VisibilityConditionType.SHOW -> VisibilityConditionTypeDto.SHOW + VisibilityConditionType.HIDE -> VisibilityConditionTypeDto.HIDE + } + + private fun toVisibilityConditionType(typeDto: VisibilityConditionTypeDto): VisibilityConditionType = + when (typeDto) { + VisibilityConditionTypeDto.SHOW -> VisibilityConditionType.SHOW + VisibilityConditionTypeDto.HIDE -> VisibilityConditionType.HIDE + } + + private fun toVisibilityConditionOperatorDto( + operator: VisibilityConditionOperator, + ): VisibilityConditionOperatorDto = + when (operator) { + VisibilityConditionOperator.EQUALS -> + VisibilityConditionOperatorDto.EQUALS + VisibilityConditionOperator.NOT_EQUALS -> + VisibilityConditionOperatorDto.NOT_EQUALS + VisibilityConditionOperator.IS_EMPTY -> + VisibilityConditionOperatorDto.IS_EMPTY + VisibilityConditionOperator.IS_NOT_EMPTY -> + VisibilityConditionOperatorDto.IS_NOT_EMPTY + } + + private fun toVisibilityConditionOperator( + operatorDto: VisibilityConditionOperatorDto, + ): VisibilityConditionOperator = + when (operatorDto) { + VisibilityConditionOperatorDto.EQUALS -> + VisibilityConditionOperator.EQUALS + VisibilityConditionOperatorDto.NOT_EQUALS -> + VisibilityConditionOperator.NOT_EQUALS + VisibilityConditionOperatorDto.IS_EMPTY -> + VisibilityConditionOperator.IS_EMPTY + VisibilityConditionOperatorDto.IS_NOT_EMPTY -> + VisibilityConditionOperator.IS_NOT_EMPTY + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionOperator.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionOperator.kt new file mode 100644 index 0000000..7782569 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionOperator.kt @@ -0,0 +1,8 @@ +package com.betriebsratkanzlei.legalconsenthub.form_element + +enum class VisibilityConditionOperator { + EQUALS, + NOT_EQUALS, + IS_EMPTY, + IS_NOT_EMPTY, +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionType.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionType.kt new file mode 100644 index 0000000..7a81b43 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/VisibilityConditionType.kt @@ -0,0 +1,6 @@ +package com.betriebsratkanzlei.legalconsenthub.form_element + +enum class VisibilityConditionType { + SHOW, + HIDE, +} diff --git a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql index bbe5afe..27df93d 100644 --- a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql +++ b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql @@ -62,12 +62,17 @@ create table form_element_options create table form_element ( - form_element_order integer, - type smallint not null check (type between 0 and 5), - form_element_sub_section_id uuid not null, - id uuid not null, - description varchar(255), - title varchar(255), + form_element_order integer, + type smallint not null check (type between 0 and 5), + form_element_sub_section_id uuid not null, + id uuid not null, + condition_type varchar(255) check (condition_type in ('SHOW', 'HIDE')), + description varchar(255), + expected_value varchar(255), + operator varchar(255) check (operator in ('EQUALS', 'NOT_EQUALS', 'IS_EMPTY', 'IS_NOT_EMPTY')), + reference varchar(255), + source_form_element_reference varchar(255), + title varchar(255), primary key (id) ); @@ -165,4 +170,4 @@ alter table if exists form_element_sub_section alter table if exists notification add constraint FKeg1j4hnp0y4lbm0y35hgr4e8r foreign key (recipient_id) - references app_user + references app_user; diff --git a/legalconsenthub/app/components/FormEngine.vue b/legalconsenthub/app/components/FormEngine.vue index 56643e2..8883aa0 100644 --- a/legalconsenthub/app/components/FormEngine.vue +++ b/legalconsenthub/app/components/FormEngine.vue @@ -1,5 +1,5 @@