feat: Add TheTable form element

This commit is contained in:
2025-12-27 17:32:16 +01:00
parent 6fe20d3746
commit be9d2ec9d7
7 changed files with 291 additions and 3 deletions

View File

@@ -99,7 +99,7 @@ Application Form
└── Form Elements (FormElement) └── Form Elements (FormElement)
├── id (UUID - generated by backend) ├── id (UUID - generated by backend)
├── reference (string - custom key like "art_der_massnahme") ├── reference (string - custom key like "art_der_massnahme")
├── type (SELECT, CHECKBOX, RADIOBUTTON, TEXTFIELD, TEXTAREA, SWITCH, RICH_TEXT, DATE) ├── type (SELECT, CHECKBOX, RADIOBUTTON, TEXTFIELD, TEXTAREA, SWITCH, RICH_TEXT, DATE, TABLE)
├── title, description ├── title, description
├── options (FormOption[]) ├── options (FormOption[])
│ ├── value, label │ ├── value, label
@@ -369,6 +369,62 @@ Form elements can trigger the dynamic creation of full sections based on user in
- `useSectionSpawning`: Handles spawn trigger evaluation and template cloning - `useSectionSpawning`: Handles spawn trigger evaluation and template cloning
- `useClonableElements`: Handles element cloning with reference generation and deep cloning - `useClonableElements`: Handles element cloning with reference generation and deep cloning
### 12. Table Form Element
The TABLE form element type provides editable tabular data input with dynamic row management.
**Data Structure**:
Table data is stored using the existing `FormOptionDto` array:
- Each option represents a **column**
- `option.label` = column header
- `option.value` = JSON array of cell values for that column (e.g., `'["row1", "row2"]'`)
- Row count is determined by the JSON array length
**Example Configuration (YAML)**:
```yaml
- reference: module_overview_table
title: Module Overview
type: TABLE
options:
- value: '[]'
label: Modulname
processingPurpose: SYSTEM_OPERATION
employeeDataCategory: NON_CRITICAL
- value: '[]'
label: Beschreibung
processingPurpose: SYSTEM_OPERATION
employeeDataCategory: NON_CRITICAL
- value: '[]'
label: Verantwortlicher
processingPurpose: BUSINESS_PROCESS
employeeDataCategory: REVIEW_REQUIRED
```
**Frontend Behavior**:
- Uses Nuxt UI `UTable` component with custom cell slots
- Each cell renders a `UInput` for text entry
- Users can dynamically add/remove rows
- Component: `legalconsenthub/app/components/formelements/TheTable.vue`
**Backend Behavior**:
- PDF export renders table data as a LaTeX tabular environment
- Empty cells display "-" placeholder
- Handled in `ApplicationFormFormatService.renderTableValue()`
**Versioning**:
- Table data is fully versioned as part of form snapshots
- Column structure (headers) and row data are both preserved
**Future Extensibility**:
- Designed to support other cell types (dropdown, date picker) in future iterations
- Column type configuration can be added via option metadata
--- ---
## Project Structure ## Project Structure
@@ -749,6 +805,7 @@ act -n
- Verify these render correctly (with saved values): - Verify these render correctly (with saved values):
- `TEXTFIELD`, `TEXTAREA`, `DATE`, `RICH_TEXT` - `TEXTFIELD`, `TEXTAREA`, `DATE`, `RICH_TEXT`
- `SELECT`, `RADIOBUTTON`, `CHECKBOX`, `SWITCH` - `SELECT`, `RADIOBUTTON`, `CHECKBOX`, `SWITCH`
- `TABLE` (verify rows/columns render as LaTeX tabular)
- If you changed backend filtering or templating, ensure: - If you changed backend filtering or templating, ensure:
- Template sections (`isTemplate=true`) remain excluded from export - Template sections (`isTemplate=true`) remain excluded from export
- Spawned sections (`isTemplate=false`, `spawnedFromElementReference` set) are included - Spawned sections (`isTemplate=false`, `spawnedFromElementReference` set) are included

View File

@@ -1456,6 +1456,7 @@ components:
- SWITCH - SWITCH
- RICH_TEXT - RICH_TEXT
- DATE - DATE
- TABLE
FormElementVisibilityCondition: FormElementVisibilityCondition:
type: object type: object

View File

@@ -11,6 +11,8 @@ import com.betriebsratkanzlei.legalconsenthub_api.model.ApplicationFormSnapshotD
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSectionSnapshotDto import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSectionSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSnapshotDto import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSnapshotDto
import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSubSectionSnapshotDto import com.betriebsratkanzlei.legalconsenthub_api.model.FormElementSubSectionSnapshotDto
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.thymeleaf.TemplateEngine import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context import org.thymeleaf.context.Context
@@ -126,9 +128,53 @@ class ApplicationFormFormatService(
"SWITCH" -> { "SWITCH" -> {
if (element.options.any { it.value == "true" }) "Ja" else "Nein" if (element.options.any { it.value == "true" }) "Ja" else "Nein"
} }
"TABLE" -> {
renderTableValue(element)
}
else -> "Keine Auswahl getroffen" else -> "Keine Auswahl getroffen"
} }
private fun renderTableValue(element: FormElementSnapshotDto): String {
if (element.options.isEmpty()) return "Keine Daten"
val objectMapper = jacksonObjectMapper()
val headers = element.options.map { LatexEscaper.escape(it.label ?: "") }
val columnData =
element.options.map { option ->
try {
val typeRef = object : TypeReference<List<String>>() {}
objectMapper.readValue(option.value ?: "[]", typeRef)
} catch (e: Exception) {
emptyList<String>()
}
}
val rowCount = columnData.maxOfOrNull { col -> col.size } ?: 0
if (rowCount == 0) return "Keine Daten"
val columnSpec = headers.joinToString(" | ") { "l" }
val headerRow = headers.joinToString(" & ")
val dataRows =
(0 until rowCount).map { rowIndex ->
columnData.joinToString(" & ") { col: List<String> ->
val value = col.getOrNull(rowIndex) ?: ""
if (value.isBlank()) "-" else LatexEscaper.escape(value)
}
}
return buildString {
appendLine("\\begin{tabular}{$columnSpec}")
appendLine("\\hline")
appendLine("$headerRow \\\\")
appendLine("\\hline")
dataRows.forEach { row: String ->
appendLine("$row \\\\")
}
appendLine("\\hline")
appendLine("\\end{tabular}")
}
}
private fun filterVisibleElements(snapshot: ApplicationFormSnapshotDto): ApplicationFormSnapshotDto { private fun filterVisibleElements(snapshot: ApplicationFormSnapshotDto): ApplicationFormSnapshotDto {
val allElements = collectAllFormElements(snapshot) val allElements = collectAllFormElements(snapshot)
val formElementsByRef = buildSnapshotFormElementsByRefMap(allElements) val formElementsByRef = buildSnapshotFormElementsByRefMap(allElements)

View File

@@ -166,6 +166,8 @@ function getResolvedComponent(formElement: FormElementDto) {
return resolveComponent('TheEditor') return resolveComponent('TheEditor')
case 'DATE': case 'DATE':
return resolveComponent('TheDate') return resolveComponent('TheDate')
case 'TABLE':
return resolveComponent('TheTable')
default: default:
return resolveComponent('Unimplemented') return resolveComponent('Unimplemented')
} }

View File

@@ -0,0 +1,170 @@
<template>
<div class="space-y-3">
<UTable :data="tableData" :columns="tableColumns" class="w-full">
<template v-for="col in dataColumns" :key="col.key" #[`${col.key}-cell`]="slotProps">
<UInput
:model-value="getCellValue(slotProps.row as TableRow<TableRowData>, col.key)"
:disabled="disabled"
class="w-full min-w-32"
@update:model-value="
(val: string | number) => updateCell((slotProps.row as TableRow<TableRowData>).index, col.key, String(val))
"
/>
</template>
<template #actions-cell="{ row }">
<UButton
v-if="!disabled"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
:aria-label="$t('applicationForms.formElements.table.removeRow')"
@click="removeRow(row.index)"
/>
</template>
</UTable>
<div v-if="tableData.length === 0" class="text-center text-dimmed py-4">
{{ $t('applicationForms.formElements.table.noData') }}
</div>
<UButton v-if="!disabled" variant="outline" size="sm" leading-icon="i-lucide-plus" @click="addRow">
{{ $t('applicationForms.formElements.table.addRow') }}
</UButton>
</div>
</template>
<script setup lang="ts">
import type { FormOptionDto } from '~~/.api-client'
import type { TableColumn, TableRow } from '@nuxt/ui'
const props = defineProps<{
formOptions: FormOptionDto[]
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
type TableRowData = Record<string, string>
interface DataColumn {
key: string
colIndex: number
}
const dataColumns = computed<DataColumn[]>(() =>
props.formOptions.map((_, index) => ({
key: `col_${index}`,
colIndex: index
}))
)
const tableColumns = computed<TableColumn<TableRowData>[]>(() => {
const columns: TableColumn<TableRowData>[] = props.formOptions.map((option, index) => ({
accessorKey: `col_${index}`,
header: option.label || ''
}))
if (!props.disabled) {
columns.push({
id: 'actions',
header: ''
})
}
return columns
})
const tableData = computed<TableRowData[]>(() => {
if (props.formOptions.length === 0) return []
const columnData: string[][] = props.formOptions.map((option) => {
try {
const parsed = JSON.parse(option.value || '[]')
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
})
const rowCount = Math.max(...columnData.map((col) => col.length), 0)
const rows: TableRowData[] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row: TableRowData = {}
props.formOptions.forEach((_, colIndex) => {
row[`col_${colIndex}`] = columnData[colIndex]?.[rowIndex] ?? ''
})
rows.push(row)
}
return rows
})
function getCellValue(row: TableRow<TableRowData>, columnKey: string): string {
return row.original[columnKey] ?? ''
}
function updateCell(rowIndex: number, columnKey: string, value: string) {
const colIndex = parseInt(columnKey.replace('col_', ''), 10)
const updatedOptions = props.formOptions.map((option, index) => {
if (index !== colIndex) return option
let columnValues: string[]
try {
columnValues = JSON.parse(option.value || '[]')
if (!Array.isArray(columnValues)) columnValues = []
} catch {
columnValues = []
}
while (columnValues.length <= rowIndex) {
columnValues.push('')
}
columnValues[rowIndex] = value
return { ...option, value: JSON.stringify(columnValues) }
})
emit('update:formOptions', updatedOptions)
}
function addRow() {
const updatedOptions = props.formOptions.map((option) => {
let columnValues: string[]
try {
columnValues = JSON.parse(option.value || '[]')
if (!Array.isArray(columnValues)) columnValues = []
} catch {
columnValues = []
}
columnValues.push('')
return { ...option, value: JSON.stringify(columnValues) }
})
emit('update:formOptions', updatedOptions)
}
function removeRow(rowIndex: number) {
const updatedOptions = props.formOptions.map((option) => {
let columnValues: string[]
try {
columnValues = JSON.parse(option.value || '[]')
if (!Array.isArray(columnValues)) columnValues = []
} catch {
columnValues = []
}
columnValues.splice(rowIndex, 1)
return { ...option, value: JSON.stringify(columnValues) }
})
emit('update:formOptions', updatedOptions)
}
</script>

View File

@@ -23,7 +23,13 @@
"title": "Titel", "title": "Titel",
"text": "Text", "text": "Text",
"unimplemented": "Element nicht implementiert:", "unimplemented": "Element nicht implementiert:",
"richTextPlaceholder": "Schreiben Sie hier Ihre Ergänzungen..." "richTextPlaceholder": "Schreiben Sie hier Ihre Ergänzungen...",
"table": {
"addRow": "Zeile hinzufügen",
"removeRow": "Zeile entfernen",
"emptyValue": "Keine Eingabe",
"noData": "Keine Daten vorhanden"
}
}, },
"status": { "status": {
"draft": "Entwurf", "draft": "Entwurf",

View File

@@ -23,7 +23,13 @@
"title": "Title", "title": "Title",
"text": "Text", "text": "Text",
"unimplemented": "Element unimplemented:", "unimplemented": "Element unimplemented:",
"richTextPlaceholder": "Write your additions here..." "richTextPlaceholder": "Write your additions here...",
"table": {
"addRow": "Add row",
"removeRow": "Remove row",
"emptyValue": "No input",
"noData": "No data available"
}
}, },
"status": { "status": {
"draft": "Draft", "draft": "Draft",