feat(#5): Add title-body control element that can be added dynamically, refactored sectionIndex/create

This commit is contained in:
2025-11-02 10:32:46 +01:00
parent 4d371be2e3
commit 736cd17789
12 changed files with 407 additions and 88 deletions

8
CHANGELOG.md Normal file
View File

@@ -0,0 +1,8 @@
# Changelog
## 2025-11-02 - Form Element Management API
- New input field with title and text needed to be added to application forms
- Adding new form elements to existing application forms caused type conflicts: frontend needed to add `CreateFormElementDto` (without ID) to arrays of `FormElementDto` (with ID)
- Implemented separate endpoint for adding form elements: `POST /application-forms/{applicationFormId}/sections/{sectionId}/form-elements`

View File

@@ -224,6 +224,57 @@ paths:
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
/application-forms/{applicationFormId}/sections/{sectionId}/form-elements:
post:
summary: Add a new form element to a specific section
operationId: addFormElementToSection
tags:
- application-form
parameters:
- name: applicationFormId
in: path
required: true
schema:
type: string
format: uuid
description: The ID of the application form
- name: sectionId
in: path
required: true
schema:
type: string
format: uuid
description: The ID of the form element section
- name: position
in: query
required: true
schema:
type: integer
description: The position to insert the form element
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateFormElementDto"
responses:
"201":
description: Form element successfully added
content:
application/json:
schema:
$ref: "#/components/schemas/ApplicationFormDto"
"400":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
"401":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
"404":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/NotFound"
"500":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
"503":
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServiceUnavailable"
####### Application Form Templates #######
/application-form-templates:
get:
@@ -1080,6 +1131,7 @@ components:
- RADIOBUTTON
- TEXTFIELD
- SWITCH
- TITLE_BODY_TEXTFIELDS
####### UserDto #######
UserDto:

View File

@@ -3,6 +3,7 @@ package com.betriebsratkanzlei.legalconsenthub.application_form
import com.betriebsratkanzlei.legalconsenthub_api.api.ApplicationFormApi
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateFormElementDto
import com.betriebsratkanzlei.legalconsenthub_api.model.PagedApplicationFormDto
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
@@ -108,4 +109,24 @@ class ApplicationFormController(
applicationFormService.submitApplicationForm(id),
),
)
@PreAuthorize(
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL')",
)
override fun addFormElementToSection(
applicationFormId: UUID,
sectionId: UUID,
position: Int,
createFormElementDto: CreateFormElementDto,
): ResponseEntity<ApplicationFormDto> =
ResponseEntity.status(201).body(
applicationFormMapper.toApplicationFormDto(
applicationFormService.addFormElementToSection(
applicationFormId,
sectionId,
createFormElementDto,
position,
),
),
)
}

View File

@@ -5,10 +5,13 @@ import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotCreatedExc
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotDeletedException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotFoundException
import com.betriebsratkanzlei.legalconsenthub.error.ApplicationFormNotUpdatedException
import com.betriebsratkanzlei.legalconsenthub.error.FormElementSectionNotFoundException
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementMapper
import com.betriebsratkanzlei.legalconsenthub.notification.NotificationService
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormStatus
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateApplicationFormDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateFormElementDto
import com.betriebsratkanzlei.legalconsenthub_api.model.CreateNotificationDto
import com.betriebsratkanzlei.legalconsenthub_api.model.NotificationType
import org.springframework.data.domain.Page
@@ -20,6 +23,7 @@ import java.util.UUID
class ApplicationFormService(
private val applicationFormRepository: ApplicationFormRepository,
private val applicationFormMapper: ApplicationFormMapper,
private val formElementMapper: FormElementMapper,
private val notificationService: NotificationService,
) {
fun createApplicationForm(createApplicationFormDto: CreateApplicationFormDto): ApplicationForm {
@@ -45,7 +49,6 @@ class ApplicationFormService(
}
fun updateApplicationForm(applicationFormDto: ApplicationFormDto): ApplicationForm {
// TODO find statt mappen?
val applicationForm = applicationFormMapper.toApplicationForm(applicationFormDto)
val updatedApplicationForm: ApplicationForm
@@ -112,4 +115,35 @@ class ApplicationFormService(
notificationService.createNotificationForOrganization(createNotificationDto)
}
fun addFormElementToSection(
applicationFormId: UUID,
sectionId: UUID,
createFormElementDto: CreateFormElementDto,
position: Int,
): ApplicationForm {
val applicationForm = getApplicationFormById(applicationFormId)
val section =
applicationForm.formElementSections
.find { it.id == sectionId }
?: throw FormElementSectionNotFoundException(sectionId)
val newFormElement = formElementMapper.toFormElement(createFormElementDto, section)
if (position >= 0 && position < section.formElements.size) {
section.formElements.add(position, newFormElement)
} else {
section.formElements.add(newFormElement)
}
val updatedApplicationForm =
try {
applicationFormRepository.save(applicationForm)
} catch (e: Exception) {
throw ApplicationFormNotUpdatedException(e, applicationFormId)
}
return updatedApplicationForm
}
}

View File

@@ -1,28 +1,28 @@
<template>
<template v-for="(formElement, index) in props.modelValue" :key="formElement.id">
<div class="group py-3 lg:py-4">
<p v-if="formElement.title" class="font-semibold">{{ formElement.title }}</p>
<p v-if="formElement.description" class="text-dimmed pb-3">{{ formElement.description }}</p>
<div class="flex justify-between">
<div class="flex py-3 lg:py-4">
<div class="group flex-auto">
<p v-if="formElement.title" class="font-semibold">{{ formElement.title }}</p>
<p v-if="formElement.description" class="text-dimmed pb-3">{{ formElement.description }}</p>
<component
:is="getResolvedComponent(formElement)"
:form-options="formElement.options"
:disabled="props.disabled"
@update:form-options="updateFormOptions($event, index)"
/>
<UIcon
name="i-lucide-message-square-more"
class="size-5 cursor-pointer hidden group-hover:block"
@click="toggleComments(formElement.id)"
<TheComment
v-if="applicationFormId && activeFormElement === formElement.id"
:form-element-id="formElement.id"
:application-form-id="applicationFormId"
:comments="comments?.[formElement.id]"
/>
</div>
<div>
<UDropdownMenu :items="getDropdownItems(formElement.id, index)" :content="{ align: 'end' }">
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
</UDropdownMenu>
</div>
</div>
<TheComment
v-if="applicationFormId && activeFormElement === formElement.id"
:form-element-id="formElement.id"
:application-form-id="applicationFormId"
:comments="comments?.[formElement.id]"
/>
<USeparator />
</template>
</template>
@@ -31,6 +31,7 @@
import type { FormElementDto, FormOptionDto } from '~/.api-client'
import { resolveComponent } from 'vue'
import TheComment from '~/components/TheComment.vue'
import type { DropdownMenuItem } from '@nuxt/ui'
const props = defineProps<{
modelValue: FormElementDto[]
@@ -41,6 +42,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update:modelValue', formElementDto: FormElementDto[]): void
(e: 'click:comments', formElementId: string): void
(e: 'add:input-form', position: number): void
}>()
const commentStore = useCommentStore()
@@ -66,11 +68,30 @@ function getResolvedComponent(formElement: FormElementDto) {
return resolveComponent('TheSwitch')
case 'TEXTFIELD':
return resolveComponent('TheInput')
case 'TITLE_BODY_TEXTFIELDS':
return resolveComponent('TheTitleBodyInput')
default:
return resolveComponent('Unimplemented')
}
}
function getDropdownItems(formElementId: string, formElementPosition: number): DropdownMenuItem[] {
return [
[
{
label: 'Comments',
icon: 'i-lucide-message-square-more',
onClick: () => toggleComments(formElementId)
},
{
label: 'Add input field below',
icon: 'i-lucide-list-plus',
onClick: () => emit('add:input-form', formElementPosition)
}
]
]
}
function updateFormOptions(formOptions: FormOptionDto[], formElementIndex: number) {
const updatedModelValue = [...props.modelValue]
updatedModelValue[formElementIndex] = { ...updatedModelValue[formElementIndex], options: formOptions }

View File

@@ -0,0 +1,66 @@
<template>
<UFormField label="Titel">
<UInput v-model="title" class="w-full" :disabled="props.disabled" />
</UFormField>
<UFormField label="Text">
<UTextarea v-model="body" class="w-full" autoresize :disabled="props.disabled" />
</UFormField>
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~/.api-client'
const props = defineProps<{
formOptions: FormOptionDto[]
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const SEPARATOR = '|||'
const title = computed({
get: () => {
const currentValue = props.formOptions?.[0]?.value ?? ''
return splitValue(currentValue).title
},
set: (newTitle: string) => {
const currentValue = props.formOptions?.[0]?.value ?? ''
const { body: currentBody } = splitValue(currentValue)
const combinedValue = joinValue(newTitle, currentBody)
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...updatedModelValue[0], value: combinedValue }
emit('update:formOptions', updatedModelValue)
}
})
const body = computed({
get: () => {
const currentValue = props.formOptions?.[0]?.value ?? ''
return splitValue(currentValue).body
},
set: (newBody: string) => {
const currentValue = props.formOptions?.[0]?.value ?? ''
const { title: currentTitle } = splitValue(currentValue)
const combinedValue = joinValue(currentTitle, newBody)
const updatedModelValue = [...props.formOptions]
updatedModelValue[0] = { ...updatedModelValue[0], value: combinedValue }
emit('update:formOptions', updatedModelValue)
}
})
function splitValue(value: string): { title: string; body: string } {
const parts = value.split(SEPARATOR)
return {
title: parts[0] || '',
body: parts[1] || ''
}
}
function joinValue(title: string, body: string): string {
return `${title}${SEPARATOR}${body}`
}
</script>

View File

@@ -1,4 +1,9 @@
import { type CreateApplicationFormDto, type ApplicationFormDto, type PagedApplicationFormDto } from '~/.api-client'
import type {
CreateApplicationFormDto,
CreateFormElementDto,
ApplicationFormDto,
PagedApplicationFormDto
} from '~/.api-client'
import { useApplicationFormApi } from './useApplicationFormApi'
export function useApplicationForm() {
@@ -71,12 +76,36 @@ export function useApplicationForm() {
}
}
async function addFormElementToSection(
applicationFormId: string,
sectionId: string,
createFormElementDto: CreateFormElementDto,
position: number
): Promise<ApplicationFormDto> {
if (!applicationFormId || !sectionId) {
return Promise.reject(new Error('Application form ID or section ID missing'))
}
try {
return await applicationFormApi.addFormElementToSection(
applicationFormId,
sectionId,
createFormElementDto,
position
)
} catch (e: unknown) {
console.error(`Failed adding form element to section ${sectionId}:`, e)
return Promise.reject(e)
}
}
return {
createApplicationForm,
getAllApplicationForms,
getApplicationFormById,
updateApplicationForm,
deleteApplicationFormById,
submitApplicationForm
submitApplicationForm,
addFormElementToSection
}
}

View File

@@ -2,6 +2,7 @@ import {
ApplicationFormApi,
Configuration,
type CreateApplicationFormDto,
type CreateFormElementDto,
type ApplicationFormDto,
type PagedApplicationFormDto
} from '~/.api-client'
@@ -53,12 +54,27 @@ export function useApplicationFormApi() {
return applicationFormApiClient.submitApplicationForm({ id })
}
async function addFormElementToSection(
applicationFormId: string,
sectionId: string,
createFormElementDto: CreateFormElementDto,
position: number
): Promise<ApplicationFormDto> {
return applicationFormApiClient.addFormElementToSection({
applicationFormId,
sectionId,
createFormElementDto,
position
})
}
return {
createApplicationForm,
getAllApplicationForms,
getApplicationFormById,
updateApplicationForm,
deleteApplicationFormById,
submitApplicationForm
submitApplicationForm,
addFormElementToSection
}
}

View File

@@ -0,0 +1,48 @@
import type { ApplicationFormDto, CreateFormElementDto, FormElementSectionDto } from '~/.api-client'
import type { MaybeRefOrGetter } from 'vue'
export function useFormElementManagement(
currentFormElementSection: MaybeRefOrGetter<FormElementSectionDto | undefined>,
applicationFormId?: string
) {
const { addFormElementToSection } = useApplicationForm()
async function addInputFormToApplicationForm(position: number): Promise<ApplicationFormDto | undefined> {
const section = toValue(currentFormElementSection)
if (!section) return
const { formElements } = section
const inputFormElement: CreateFormElementDto = {
title: 'Formular ergänzen',
description: 'Bitte fügen Sie hier Ihre Ergänzungen ein.',
options: [
{
value: '|||',
label: '',
processingPurpose: 'NONE',
employeeDataCategory: 'NONE'
}
],
type: 'TITLE_BODY_TEXTFIELDS'
}
if (applicationFormId) {
try {
return await addFormElementToSection(applicationFormId, section.id, inputFormElement, position + 1)
} catch (error) {
console.error('Failed to add form element:', error)
throw error
}
} else {
// @ts-expect-error Add CreateFormElementDto to formElements array. ID will be generated by the backend.
formElements.splice(position + 1, 0, inputFormElement)
return undefined
}
}
return {
addInputFormToApplicationForm
}
}

View File

@@ -0,0 +1,58 @@
import type { FormElementSectionDto } from '~/.api-client'
import type { StepperItem } from '@nuxt/ui'
import type { MaybeRefOrGetter } from 'vue'
interface Stepper {
hasPrev: boolean
hasNext: boolean
next: () => void
prev: () => void
}
export function useFormStepper(
formElementSections: MaybeRefOrGetter<FormElementSectionDto[] | undefined>,
options?: {
onNavigate?: (direction: 'forward' | 'backward', newIndex: number) => void | Promise<void>
}
) {
const stepper = useTemplateRef<Stepper>('stepper')
const activeStepperItemIndex = ref<number>(0)
const sections = computed(() => toValue(formElementSections) ?? [])
const stepperItems = computed(() => {
const items: StepperItem[] = []
sections.value.forEach((section: FormElementSectionDto) => {
items.push({
title: section.shortTitle,
description: section.description
})
})
return items
})
const currentFormElementSection = computed<FormElementSectionDto | undefined>(
() => sections.value[activeStepperItemIndex.value]
)
async function navigateStepper(direction: 'forward' | 'backward') {
if (direction === 'forward') {
stepper.value?.next()
} else {
stepper.value?.prev()
}
if (options?.onNavigate) {
await options.onNavigate(direction, activeStepperItemIndex.value)
}
}
return {
stepper,
activeStepperItemIndex,
stepperItems,
currentFormElementSection,
navigateStepper
}
}

View File

@@ -31,15 +31,16 @@
<template #body>
<div class="flex flex-col w-full lg:max-w-4xl mx-auto">
<UStepper ref="stepper" v-model="activeStepperItemIndex" :items="stepperItems" class="w-full" />
<h1 class="text-xl text-pretty font-bold text-highlighted">
<h1 v-if="currentFormElementSection?.title" class="text-xl text-pretty font-bold text-highlighted">
{{ currentFormElementSection.title }}
</h1>
<UCard variant="subtle">
<FormEngine
v-if="applicationForm"
v-if="applicationForm && currentFormElementSection?.formElements"
v-model="currentFormElementSection.formElements"
:application-form-id="applicationForm.id"
:disabled="isReadOnly"
@add:input-form="handleAddInputForm"
/>
<div class="flex gap-2 justify-between mt-4">
<UButton
@@ -75,8 +76,7 @@
</template>
<script setup lang="ts">
import type { ApplicationFormDto, FormElementSectionDto } from '~/.api-client'
import type { StepperItem } from '@nuxt/ui'
import type { ApplicationFormDto } from '~/.api-client'
const { getApplicationFormById, updateApplicationForm, submitApplicationForm } = useApplicationForm()
const route = useRoute()
@@ -90,11 +90,6 @@ definePageMeta({
key: (route) => `${route.params.id}`
})
onMounted(() => {
const sectionIndex = parseInt(route.params.sectionIndex[0])
activeStepperItemIndex.value = !isNaN(sectionIndex) ? sectionIndex : 0
})
const items = [
[
{
@@ -105,13 +100,6 @@ const items = [
]
]
const stepper = useTemplateRef('stepper')
const activeStepperItemIndex = ref<number>(0)
const currentFormElementSection = computed<FormElementSectionDto>(
() => applicationForm.value?.formElementSections[activeStepperItemIndex.value]
)
const { data, error } = await useAsyncData<ApplicationFormDto>(`application-form-${route.params.id}`, async () => {
console.log('Fetching application form with ID:', route.params.id)
return await getApplicationFormById(Array.isArray(route.params.id) ? route.params.id[0] : route.params.id)
@@ -124,29 +112,35 @@ if (error.value) {
const applicationForm = computed<ApplicationFormDto>(() => data?.value as ApplicationFormDto)
const isReadOnly = computed(() => {
return applicationForm.value?.createdBy.id !== user.value?.id
return applicationForm.value?.createdBy.keycloakId !== user.value?.keycloakId
})
const stepperItems = computed(() => {
const stepperItems: StepperItem[] = []
applicationForm.value.formElementSections.forEach((section: FormElementSectionDto) => {
stepperItems.push({
title: section.shortTitle,
description: section.description
})
})
return stepperItems
})
async function navigateStepper(direction: 'forward' | 'backward') {
if (direction === 'forward') {
stepper.value?.next()
} else {
stepper.value?.prev()
const { stepper, activeStepperItemIndex, stepperItems, currentFormElementSection, navigateStepper } = useFormStepper(
computed(() => applicationForm.value?.formElementSections),
{
onNavigate: async () => {
await navigateTo(`/application-forms/${route.params.id}/${activeStepperItemIndex.value}`)
}
}
)
const { addInputFormToApplicationForm } = useFormElementManagement(
currentFormElementSection,
applicationForm.value?.id
)
async function handleAddInputForm(position: number) {
const updatedForm = await addInputFormToApplicationForm(position)
if (updatedForm) {
data.value = updatedForm
}
await navigateTo(`/application-forms/${route.params.id}/${activeStepperItemIndex.value}`)
}
onMounted(() => {
const sectionIndex = parseInt(route.params.sectionIndex[0])
activeStepperItemIndex.value = !isNaN(sectionIndex) ? sectionIndex : 0
})
async function onSave() {
if (data?.value) {
await updateApplicationForm(data.value.id, data.value)

View File

@@ -39,6 +39,7 @@
<FormEngine
v-if="currentFormElementSection?.formElements"
v-model="currentFormElementSection.formElements"
@add:input-form="addInputFormToApplicationForm"
/>
<div class="flex gap-2 justify-between mt-4">
<UButton
@@ -71,10 +72,9 @@
</template>
<script setup lang="ts">
import { ComplianceStatus, type FormElementSectionDto, type PagedApplicationFormDto } from '~/.api-client'
import { ComplianceStatus, type PagedApplicationFormDto } from '~/.api-client'
import { useApplicationFormValidator } from '~/composables/useApplicationFormValidator'
import type { FormElementId } from '~/types/formElement'
import type { StepperItem } from '@nuxt/ui'
const { getAllApplicationFormTemplates } = await useApplicationFormTemplate()
const { createApplicationForm, submitApplicationForm } = useApplicationForm()
@@ -84,17 +84,6 @@ const userStore = useUserStore()
const { selectedOrganization } = storeToRefs(userStore)
const toast = useToast()
const stepper = useTemplateRef('stepper')
const activeStepperItemIndex = ref<number>(0)
const currentFormElementSection = computed(
() => applicationFormTemplate.value?.formElementSections[activeStepperItemIndex.value]
)
watch(activeStepperItemIndex, async (newActiveStepperItem: number) => {
activeStepperItemIndex.value = newActiveStepperItem
})
const { data, error } = await useAsyncData<PagedApplicationFormDto>(async () => {
return await getAllApplicationFormTemplates()
})
@@ -103,34 +92,17 @@ if (error.value) {
throw createError({ statusText: error.value.message })
}
const stepperItems = computed(() => {
const stepperItems: StepperItem[] = []
if (!applicationFormTemplate.value) {
return stepperItems
}
applicationFormTemplate.value.formElementSections.forEach((section: FormElementSectionDto) => {
stepperItems.push({
title: section.shortTitle,
description: section.description
})
})
return stepperItems
})
async function navigateStepper(direction: 'forward' | 'backward') {
if (direction === 'forward') {
stepper.value?.next()
} else {
stepper.value?.prev()
}
}
const applicationFormTemplate = computed(
// TODO: Don't select always the first item, allow user to select a template
() => data?.value?.content[0] ?? undefined
)
const { stepper, activeStepperItemIndex, stepperItems, currentFormElementSection, navigateStepper } = useFormStepper(
computed(() => applicationFormTemplate.value?.formElementSections)
)
const { addInputFormToApplicationForm } = useFormElementManagement(currentFormElementSection)
const formElements = computed({
get: () => currentFormElementSection?.value?.formElements ?? [],
set: (val) => {