440 lines
13 KiB
TypeScript
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/formDiff'
|
|
|
|
// 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
|
|
}
|