feat: Add TheTable form element
This commit is contained in:
59
CLAUDE.md
59
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -1456,6 +1456,7 @@ components:
|
|||||||
- SWITCH
|
- SWITCH
|
||||||
- RICH_TEXT
|
- RICH_TEXT
|
||||||
- DATE
|
- DATE
|
||||||
|
- TABLE
|
||||||
|
|
||||||
FormElementVisibilityCondition:
|
FormElementVisibilityCondition:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
170
legalconsenthub/app/components/formelements/TheTable.vue
Normal file
170
legalconsenthub/app/components/formelements/TheTable.vue
Normal 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>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user