Files
gremiumhub/legalconsenthub/app/utils/formSnapshotComparison.ts

440 lines
13 KiB
TypeScript

import type {
ApplicationFormDto,
ApplicationFormSnapshotDto,
FormElementDto,
FormElementSnapshotDto,
FormOptionDto,
FormElementType
} from '~~/.api-client'
import type { FormValueDiff, ValueChange, SectionChanges, TableDiff, TableRowDiff } from '~~/types/formSnapshotComparison'
// 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
): FormValueDiff {
const diff: FormValueDiff = {
newAnswers: [],
changedAnswers: [],
clearedAnswers: []
}
const currentElements = flattenFormElements(current)
const versionElements = flattenSnapshotElements(versionSnapshot)
// Create maps by reference for matching elements
const currentByRef = new Map(
currentElements.filter((el) => el.element.reference).map((el) => [el.element.reference!, el])
)
const versionByRef = new Map(
versionElements.filter((el) => el.element.reference).map((el) => [el.element.reference!, el])
)
// 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 values are the same (actual values haven't changed)
// For tables, compare the serialized data directly since labels only show row count
if (elementType === 'TABLE') {
const currentSerialized = typeof currentValue === 'string' ? currentValue : null
const versionSerialized = typeof versionValue === 'string' ? versionValue : null
if (currentSerialized === versionSerialized) {
continue
}
} else 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 {
// Changed answer
diff.changedAnswers.push(change)
}
}
// 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<string>()
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<string, ValueChange[]>()
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<string, string>[] {
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<string, string>[] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row: Record<string, string> = {}
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) {
for (const subsection of section.formElementSubSections) {
for (const element of subsection.formElements) {
elements.push({ element, sectionTitle: section.title })
}
}
}
return elements
}
/**
* Flatten form elements from snapshot into a flat list.
*/
function flattenSnapshotElements(
snapshot: ApplicationFormSnapshotDto
): Array<{ element: FormElementSnapshotDto; sectionTitle: string }> {
const elements: Array<{ element: FormElementSnapshotDto; sectionTitle: string }> = []
for (const section of snapshot.sections) {
for (const subsection of section.subsections) {
for (const element of subsection.elements) {
elements.push({ element, sectionTitle: section.title })
}
}
}
return elements
}