-
~
-
-
{{ $t('versions.elementIn') }} {{ element.sectionTitle }}
+
+
+
+
+
+ +{{ change.tableDiff.addedCount }} {{ $t('versions.tableRowsAdded') }}
+
+
+ -{{ change.tableDiff.removedCount }} {{ $t('versions.tableRowsRemoved') }}
+
+
+ ~{{ change.tableDiff.modifiedCount }} {{ $t('versions.tableRowsModified') }}
+
+
-
-
-
- {{ $t('versions.optionsAdded', { count: element.optionsAdded.length }) }}:
-
-
- +
- {{ option.label }} ({{ option.value }})
-
+
+
+
+
+
+ |
+ #
+ |
+
+ {{ $t('versions.tableStatus') }}
+ |
+
+ {{ col }}
+ |
+
+
+
+
+
+
+ |
+ {{ row.rowIndex + 1 }}
+ |
+
+
+ {{ getStatusLabel(row.changeType) }}
+
+ |
+
+
+ {{ row.currentValues[col] || '-' }}
+
+
+ {{ row.previousValues[col] || '-' }}
+
+
+
+
+ {{ row.previousValues[col] || '-' }}
+
+ {{ row.currentValues[col] || '-' }}
+
+ {{ row.currentValues[col] || '-' }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('versions.before') }}:
+
+ {{ change.previousLabel || $t('versions.noValue') }}
+
+
-
-
- {{ $t('versions.optionsRemoved', { count: element.optionsRemoved.length }) }}:
-
-
- -
- {{ option.label }} ({{ option.value }})
-
-
+
+
+
+
-
-
- {{ $t('versions.optionsModified', { count: element.optionsModified.length }) }}:
-
-
-
- {{ option.value }}:
- {{ option.labelChanged.from }}
-
- {{ option.labelChanged.to }}
-
-
+
+
+
+
+ {{ $t('versions.after') }}:
+
+ {{ change.currentLabel || $t('versions.noValue') }}
+
-
-
+
+
-
{{ $t('versions.noChanges') }}
+
+
+
{{ $t('versions.noChanges') }}
+
@@ -132,9 +181,10 @@
diff --git a/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue b/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue
index b1d37bd..7b109bc 100644
--- a/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue
+++ b/legalconsenthub/app/pages/application-forms/[id]/[sectionIndex].vue
@@ -133,6 +133,11 @@ async function onSave() {
async function onSubmit() {
if (applicationForm.value?.id) {
+ // Save the form first to persist any unsaved changes before submitting
+ const updated = await updateForm(applicationForm.value.id, applicationForm.value)
+ if (updated) {
+ updateApplicationForm(updated)
+ }
await submitApplicationForm(applicationForm.value.id)
await navigateTo('/')
toast.add({ title: $t('common.success'), description: $t('applicationForms.submitted'), color: 'success' })
diff --git a/legalconsenthub/app/utils/formDiff.ts b/legalconsenthub/app/utils/formDiff.ts
index 6ae3de8..deec55a 100644
--- a/legalconsenthub/app/utils/formDiff.ts
+++ b/legalconsenthub/app/utils/formDiff.ts
@@ -3,72 +3,405 @@ import type {
ApplicationFormSnapshotDto,
FormElementDto,
FormElementSnapshotDto,
- FormOptionDto
+ FormOptionDto,
+ FormElementType
} from '~~/.api-client'
-import type { FormDiff, ElementModification, OptionChange, OptionModification } from '~~/types/formDiff'
+import type { FormValueDiff, ValueChange, SectionChanges, TableDiff, TableRowDiff } from '~~/types/formDiff'
-export function compareApplicationForms(
+// Element types that use true/false selection model
+const SELECTION_TYPES: FormElementType[] = ['SELECT', 'RADIOBUTTON', 'CHECKBOX', 'SWITCH']
+
+// Element types that store text in the first option's value
+const TEXT_INPUT_TYPES: FormElementType[] = ['TEXTFIELD', 'TEXTAREA', 'RICH_TEXT', 'DATE']
+
+/**
+ * Compare two application forms and return a value-based diff for improved UX.
+ * This focuses on what values changed rather than technical option-level changes.
+ */
+export function compareApplicationFormValues(
current: ApplicationFormDto,
versionSnapshot: ApplicationFormSnapshotDto
-): FormDiff {
- const diff: FormDiff = {
- elementsAdded: [],
- elementsRemoved: [],
- elementsModified: []
+): FormValueDiff {
+ const diff: FormValueDiff = {
+ newAnswers: [],
+ changedAnswers: [],
+ clearedAnswers: []
}
const currentElements = flattenFormElements(current)
const versionElements = flattenSnapshotElements(versionSnapshot)
- const currentElementsMap = new Map(
- currentElements.map((el, idx) => [
- createElementKey(el.element, idx),
- { element: el.element, index: idx, sectionTitle: el.sectionTitle }
- ])
+ // Create maps by reference for matching elements
+ const currentByRef = new Map(
+ currentElements.filter((el) => el.element.reference).map((el) => [el.element.reference!, el])
)
- const versionElementsMap = new Map(
- versionElements.map((el, idx) => [
- createSnapshotElementKey(el.element, idx),
- { element: el.element, index: idx, sectionTitle: el.sectionTitle }
- ])
+ const versionByRef = new Map(
+ versionElements.filter((el) => el.element.reference).map((el) => [el.element.reference!, el])
)
- for (const [key, currentData] of currentElementsMap) {
- if (!versionElementsMap.has(key)) {
- diff.elementsAdded.push({
- sectionTitle: currentData.sectionTitle,
- title: currentData.element.title,
- type: currentData.element.type,
- position: currentData.index
- })
+ // Compare elements that exist in current form
+ for (const [ref, currentData] of currentByRef) {
+ const elementType = currentData.element.type
+ const versionData = versionByRef.get(ref)
+
+ // Extract the actual user-selected/entered value based on element type
+ const currentValue = extractUserValue(currentData.element.options, elementType)
+ const versionValue = versionData ? extractUserValue(versionData.element.options, versionData.element.type) : null
+
+ // Get human-readable labels
+ const currentLabel = formatUserValueLabel(currentValue, currentData.element.options, elementType)
+ const versionLabel = versionData
+ ? formatUserValueLabel(versionValue, versionData.element.options, versionData.element.type)
+ : null
+
+ // Skip if labels are the same (actual displayed values haven't changed)
+ if (currentLabel === versionLabel) {
+ continue
+ }
+
+ // For tables, compute structured diff
+ let tableDiff: TableDiff | undefined
+ if (elementType === 'TABLE') {
+ tableDiff = computeTableDiff(versionData?.element.options || [], currentData.element.options)
+ }
+
+ const change: ValueChange = {
+ sectionTitle: currentData.sectionTitle,
+ elementTitle: currentData.element.title || '',
+ elementType: elementType,
+ previousValue: versionValue,
+ currentValue: currentValue,
+ previousLabel: versionLabel,
+ currentLabel: currentLabel,
+ tableDiff
+ }
+
+ if (isEmptyLabel(versionLabel) && !isEmptyLabel(currentLabel)) {
+ // New answer (first time answered)
+ diff.newAnswers.push(change)
+ } else if (!isEmptyLabel(versionLabel) && isEmptyLabel(currentLabel)) {
+ // Cleared answer
+ diff.clearedAnswers.push(change)
} else {
- const versionData = versionElementsMap.get(key)!
- const modifications = compareElements(
- currentData.element,
- versionData.element,
- currentData.sectionTitle,
- currentData.index
- )
- if (modifications) {
- diff.elementsModified.push(modifications)
- }
+ // Changed answer
+ diff.changedAnswers.push(change)
}
}
- for (const [key, versionData] of versionElementsMap) {
- if (!currentElementsMap.has(key)) {
- diff.elementsRemoved.push({
- sectionTitle: versionData.sectionTitle,
- title: versionData.element.title,
- type: versionData.element.type,
- position: versionData.index
- })
+ // Check for elements that existed in version but not in current (cleared)
+ for (const [ref, versionData] of versionByRef) {
+ if (!currentByRef.has(ref)) {
+ const elementType = versionData.element.type
+ const versionValue = extractUserValue(versionData.element.options, elementType)
+ const versionLabel = formatUserValueLabel(versionValue, versionData.element.options, elementType)
+
+ if (!isEmptyLabel(versionLabel)) {
+ let tableDiff: TableDiff | undefined
+ if (elementType === 'TABLE') {
+ tableDiff = computeTableDiff(versionData.element.options, [])
+ }
+
+ diff.clearedAnswers.push({
+ sectionTitle: versionData.sectionTitle,
+ elementTitle: versionData.element.title || '',
+ elementType: elementType,
+ previousValue: versionValue,
+ currentValue: null,
+ previousLabel: versionLabel,
+ currentLabel: null,
+ tableDiff
+ })
+ }
}
}
return diff
}
+/**
+ * Compute a structured diff between two table states.
+ */
+function computeTableDiff(previousOptions: FormOptionDto[], currentOptions: FormOptionDto[]): TableDiff {
+ const previousRows = parseTableToRows(previousOptions)
+ const currentRows = parseTableToRows(currentOptions)
+
+ // Get all column labels from both tables
+ const columnSet = new Set
()
+ previousOptions.forEach((opt) => columnSet.add(opt.label))
+ currentOptions.forEach((opt) => columnSet.add(opt.label))
+ const columns = Array.from(columnSet)
+
+ const maxRows = Math.max(previousRows.length, currentRows.length)
+ const rows: TableRowDiff[] = []
+
+ let addedCount = 0
+ let removedCount = 0
+ let modifiedCount = 0
+
+ for (let i = 0; i < maxRows; i++) {
+ const prevRow = previousRows[i]
+ const currRow = currentRows[i]
+
+ if (!prevRow && currRow) {
+ // Row was added
+ rows.push({
+ rowIndex: i,
+ changeType: 'added',
+ previousValues: {},
+ currentValues: currRow
+ })
+ addedCount++
+ } else if (prevRow && !currRow) {
+ // Row was removed
+ rows.push({
+ rowIndex: i,
+ changeType: 'removed',
+ previousValues: prevRow,
+ currentValues: {}
+ })
+ removedCount++
+ } else if (prevRow && currRow) {
+ // Check if row was modified
+ const isModified = columns.some((col) => {
+ const prevVal = prevRow[col] || ''
+ const currVal = currRow[col] || ''
+ return prevVal !== currVal
+ })
+
+ rows.push({
+ rowIndex: i,
+ changeType: isModified ? 'modified' : 'unchanged',
+ previousValues: prevRow,
+ currentValues: currRow
+ })
+
+ if (isModified) {
+ modifiedCount++
+ }
+ }
+ }
+
+ return {
+ columns,
+ rows,
+ addedCount,
+ removedCount,
+ modifiedCount
+ }
+}
+
+/**
+ * Group changes by section for accordion display
+ */
+export function groupChangesBySection(diff: FormValueDiff): SectionChanges[] {
+ const allChanges = [...diff.newAnswers, ...diff.changedAnswers, ...diff.clearedAnswers]
+
+ const sectionMap = new Map()
+
+ for (const change of allChanges) {
+ const existing = sectionMap.get(change.sectionTitle) || []
+ existing.push(change)
+ sectionMap.set(change.sectionTitle, existing)
+ }
+
+ return Array.from(sectionMap.entries()).map(([sectionTitle, changes]) => ({
+ sectionTitle,
+ changes
+ }))
+}
+
+/**
+ * Extract the actual user-selected or user-entered value from form options.
+ * Different element types store values differently.
+ */
+function extractUserValue(options: FormOptionDto[], elementType: FormElementType): string | string[] | null {
+ if (!options || options.length === 0) {
+ return null
+ }
+
+ // For selection-based elements (SELECT, RADIOBUTTON, CHECKBOX, SWITCH)
+ // The selected option(s) have value === "true"
+ if (SELECTION_TYPES.includes(elementType)) {
+ const selectedOptions = options.filter((opt) => opt.value === 'true')
+
+ if (selectedOptions.length === 0) {
+ return null
+ }
+
+ // Return the labels of selected options (what the user actually sees)
+ const selectedLabels = selectedOptions.map((opt) => opt.label).filter((label): label is string => !!label)
+ if (selectedLabels.length === 0) {
+ return null
+ }
+ return selectedLabels.length === 1 ? (selectedLabels[0] ?? null) : selectedLabels
+ }
+
+ // For text input elements (TEXTFIELD, TEXTAREA, RICH_TEXT, DATE)
+ // The value is stored in the first option's value field
+ if (TEXT_INPUT_TYPES.includes(elementType)) {
+ const value = options[0]?.value
+ if (!value || value.trim() === '') {
+ return null
+ }
+ return value
+ }
+
+ // For TABLE elements - return serialized table data for comparison
+ if (elementType === 'TABLE') {
+ return serializeTableData(options)
+ }
+
+ return null
+}
+
+/**
+ * Serialize table data into a comparable string format.
+ * This captures all rows and all columns for accurate comparison.
+ */
+function serializeTableData(options: FormOptionDto[]): string | null {
+ if (!options || options.length === 0) {
+ return null
+ }
+
+ const tableRows = parseTableToRows(options)
+
+ if (tableRows.length === 0) {
+ return null
+ }
+
+ // Serialize as JSON for accurate comparison
+ return JSON.stringify(tableRows)
+}
+
+/**
+ * Parse table options into row-based data structure.
+ * Returns array of rows, where each row is an object with column labels as keys.
+ */
+function parseTableToRows(options: FormOptionDto[]): Record[] {
+ if (!options || options.length === 0) {
+ return []
+ }
+
+ // Parse all columns
+ const columnData: { label: string; values: (string | boolean | string[])[] }[] = options.map((option) => {
+ let values: (string | boolean | string[])[] = []
+ try {
+ const parsed = JSON.parse(option.value || '[]')
+ values = Array.isArray(parsed) ? parsed : []
+ } catch {
+ values = []
+ }
+ return { label: option.label, values }
+ })
+
+ // Find max row count
+ const rowCount = Math.max(...columnData.map((col) => col.values.length), 0)
+
+ if (rowCount === 0) {
+ return []
+ }
+
+ // Build rows
+ const rows: Record[] = []
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ const row: Record = {}
+
+ columnData.forEach((col) => {
+ const cellValue = col.values[rowIndex]
+ const formattedValue = formatTableCellValue(cellValue)
+ row[col.label] = formattedValue
+ })
+
+ rows.push(row)
+ }
+
+ return rows
+}
+
+/**
+ * Format a single table cell value to a readable string.
+ */
+function formatTableCellValue(value: string | boolean | string[] | undefined | null): string {
+ if (value === undefined || value === null) {
+ return ''
+ }
+
+ if (typeof value === 'boolean') {
+ return value ? 'Ja' : 'Nein'
+ }
+
+ if (Array.isArray(value)) {
+ const filtered = value.filter((v) => v && String(v).trim() !== '')
+ return filtered.length > 0 ? filtered.join(', ') : ''
+ }
+
+ if (typeof value === 'string') {
+ return value.trim()
+ }
+
+ return String(value)
+}
+
+/**
+ * Format a user value to a human-readable label.
+ * For selection types, the value IS already the label.
+ * For text types, we return the text as-is.
+ * For tables, we format as a summary.
+ */
+function formatUserValueLabel(
+ value: string | string[] | null,
+ options: FormOptionDto[],
+ elementType: FormElementType
+): string | null {
+ if (value === null || value === undefined) {
+ return null
+ }
+
+ // For tables, create a summary (row count)
+ if (elementType === 'TABLE') {
+ return formatTableSummary(options)
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return null
+ }
+ return value.join(', ')
+ }
+
+ if (typeof value === 'string' && value.trim() === '') {
+ return null
+ }
+
+ return value
+}
+
+/**
+ * Format table data into a brief summary label.
+ */
+function formatTableSummary(options: FormOptionDto[]): string | null {
+ const rows = parseTableToRows(options)
+
+ if (rows.length === 0) {
+ return null
+ }
+
+ const rowLabel = rows.length === 1 ? 'Zeile' : 'Zeilen'
+ return `${rows.length} ${rowLabel}`
+}
+
+/**
+ * Check if a label is considered "empty" (no answer given)
+ */
+function isEmptyLabel(label: string | null | undefined): boolean {
+ if (label === null || label === undefined) return true
+ if (label.trim() === '') return true
+ return false
+}
+
+/**
+ * Flatten form elements from ApplicationFormDto into a flat list.
+ */
function flattenFormElements(form: ApplicationFormDto): Array<{ element: FormElementDto; sectionTitle: string }> {
const elements: Array<{ element: FormElementDto; sectionTitle: string }> = []
for (const section of form.formElementSections) {
@@ -81,6 +414,9 @@ function flattenFormElements(form: ApplicationFormDto): Array<{ element: FormEle
return elements
}
+/**
+ * Flatten form elements from snapshot into a flat list.
+ */
function flattenSnapshotElements(
snapshot: ApplicationFormSnapshotDto
): Array<{ element: FormElementSnapshotDto; sectionTitle: string }> {
@@ -94,74 +430,3 @@ function flattenSnapshotElements(
}
return elements
}
-
-function createElementKey(element: FormElementDto, index: number): string {
- return `${index}-${element.type}-${element.title || 'untitled'}`
-}
-
-function createSnapshotElementKey(element: FormElementSnapshotDto, index: number): string {
- return `${index}-${element.type}-${element.title || 'untitled'}`
-}
-
-function compareElements(
- current: FormElementDto,
- version: FormElementSnapshotDto,
- sectionTitle: string,
- position: number
-): ElementModification | null {
- const optionsDiff = compareOptions(current.options, version.options)
-
- if (optionsDiff.added.length > 0 || optionsDiff.removed.length > 0 || optionsDiff.modified.length > 0) {
- return {
- sectionTitle,
- position,
- optionsAdded: optionsDiff.added,
- optionsRemoved: optionsDiff.removed,
- optionsModified: optionsDiff.modified
- }
- }
-
- return null
-}
-
-function compareOptions(
- currentOptions: FormOptionDto[],
- versionOptions: FormOptionDto[]
-): { added: OptionChange[]; removed: OptionChange[]; modified: OptionModification[] } {
- const result = {
- added: [] as OptionChange[],
- removed: [] as OptionChange[],
- modified: [] as OptionModification[]
- }
-
- const currentMap = new Map(currentOptions.map((opt) => [opt.value, opt]))
- const versionMap = new Map(versionOptions.map((opt) => [opt.value, opt]))
-
- for (const [value, currentOpt] of currentMap) {
- if (!versionMap.has(value)) {
- result.added.push({
- value: currentOpt.value,
- label: currentOpt.label
- })
- } else {
- const versionOpt = versionMap.get(value)!
- if (currentOpt.label !== versionOpt.label) {
- result.modified.push({
- value,
- labelChanged: { from: versionOpt.label, to: currentOpt.label }
- })
- }
- }
- }
-
- for (const [value, versionOpt] of versionMap) {
- if (!currentMap.has(value)) {
- result.removed.push({
- value: versionOpt.value,
- label: versionOpt.label
- })
- }
- }
-
- return result
-}
diff --git a/legalconsenthub/i18n/locales/de.json b/legalconsenthub/i18n/locales/de.json
index c16fd72..bbbf569 100644
--- a/legalconsenthub/i18n/locales/de.json
+++ b/legalconsenthub/i18n/locales/de.json
@@ -95,7 +95,22 @@
"optionsAdded": "Optionen hinzugefügt ({count})",
"optionsRemoved": "Optionen entfernt ({count})",
"optionsModified": "Optionen geändert ({count})",
- "noChanges": "Keine Unterschiede gefunden"
+ "noChanges": "Keine Unterschiede gefunden",
+ "changesSummary": "{count} Änderungen seit dieser Version",
+ "changesCount": "{count} Änderung | {count} Änderungen",
+ "before": "Vorher",
+ "after": "Jetzt",
+ "noValue": "Keine Angabe",
+ "newAnswer": "Neu beantwortet",
+ "changedAnswer": "Geändert",
+ "clearedAnswer": "Gelöscht",
+ "tableRowsAdded": "hinzugefügt",
+ "tableRowsRemoved": "entfernt",
+ "tableRowsModified": "geändert",
+ "tableStatus": "Status",
+ "rowAdded": "Neu",
+ "rowRemoved": "Entfernt",
+ "rowModified": "Geändert"
},
"comments": {
"title": "Kommentare",
diff --git a/legalconsenthub/i18n/locales/en.json b/legalconsenthub/i18n/locales/en.json
index a772629..c5b8872 100644
--- a/legalconsenthub/i18n/locales/en.json
+++ b/legalconsenthub/i18n/locales/en.json
@@ -95,7 +95,22 @@
"optionsAdded": "Options added ({count})",
"optionsRemoved": "Options removed ({count})",
"optionsModified": "Options modified ({count})",
- "noChanges": "No differences found"
+ "noChanges": "No differences found",
+ "changesSummary": "{count} changes since this version",
+ "changesCount": "{count} change | {count} changes",
+ "before": "Before",
+ "after": "Now",
+ "noValue": "No value",
+ "newAnswer": "Newly answered",
+ "changedAnswer": "Changed",
+ "clearedAnswer": "Cleared",
+ "tableRowsAdded": "added",
+ "tableRowsRemoved": "removed",
+ "tableRowsModified": "modified",
+ "tableStatus": "Status",
+ "rowAdded": "New",
+ "rowRemoved": "Removed",
+ "rowModified": "Modified"
},
"comments": {
"title": "Comments",
diff --git a/legalconsenthub/types/formDiff.ts b/legalconsenthub/types/formDiff.ts
index 5fbdc6d..b2b875e 100644
--- a/legalconsenthub/types/formDiff.ts
+++ b/legalconsenthub/types/formDiff.ts
@@ -1,30 +1,72 @@
-export interface FormDiff {
- elementsAdded: ElementChange[]
- elementsRemoved: ElementChange[]
- elementsModified: ElementModification[]
+import type { FormElementType } from '~~/.api-client'
+
+/**
+ * Represents a single row in a table diff.
+ */
+export interface TableRowDiff {
+ /** Row index (0-based) */
+ rowIndex: number
+ /** Type of change: 'added', 'removed', 'modified', 'unchanged' */
+ changeType: 'added' | 'removed' | 'modified' | 'unchanged'
+ /** Previous row values (keyed by column label) */
+ previousValues: Record
+ /** Current row values (keyed by column label) */
+ currentValues: Record
}
-export interface ElementChange {
+/**
+ * Structured table diff for detailed comparison display.
+ */
+export interface TableDiff {
+ /** Column labels/headers */
+ columns: string[]
+ /** Row-by-row diff */
+ rows: TableRowDiff[]
+ /** Summary counts */
+ addedCount: number
+ removedCount: number
+ modifiedCount: number
+}
+
+/**
+ * Represents a single value change in a form element.
+ */
+export interface ValueChange {
+ /** The section this element belongs to */
sectionTitle: string
- title: string | undefined
- type: string
- position: number
+ /** The title/label of the form element */
+ elementTitle: string
+ /** The type of form element (SELECT, TEXTFIELD, TABLE, etc.) */
+ elementType: FormElementType
+ /** The raw previous value (null = no previous value) */
+ previousValue: string | string[] | null
+ /** The raw current value (null = value was cleared) */
+ currentValue: string | string[] | null
+ /** Human-readable label for the previous value */
+ previousLabel: string | null
+ /** Human-readable label for the current value */
+ currentLabel: string | null
+ /** Structured table diff (only for TABLE elements) */
+ tableDiff?: TableDiff
}
-export interface ElementModification {
+/**
+ * The result of comparing two form versions.
+ * Changes are categorized by type for better UX presentation.
+ */
+export interface FormValueDiff {
+ /** Elements that were answered for the first time */
+ newAnswers: ValueChange[]
+ /** Elements where the value was changed */
+ changedAnswers: ValueChange[]
+ /** Elements where the value was cleared/removed */
+ clearedAnswers: ValueChange[]
+}
+
+/**
+ * Changes grouped by section for accordion display.
+ */
+export interface SectionChanges {
sectionTitle: string
- position: number
- optionsAdded: OptionChange[]
- optionsRemoved: OptionChange[]
- optionsModified: OptionModification[]
-}
-
-export interface OptionChange {
- value: string
- label: string
-}
-
-export interface OptionModification {
- value: string
- labelChanged: { from: string; to: string }
+ changes: ValueChange[]
}