feat(frontend): Improve version comparison
This commit is contained in:
@@ -11,116 +11,165 @@
|
||||
|
||||
<div v-else-if="error" class="text-red-500">{{ $t('versions.comparisonError') }}: {{ error }}</div>
|
||||
|
||||
<div v-else-if="diff && hasChanges" class="space-y-6">
|
||||
<div v-if="diff.elementsAdded.length > 0" class="space-y-2">
|
||||
<h4 class="font-semibold text-success flex items-center gap-2">
|
||||
<UIcon name="i-lucide-plus-circle" />
|
||||
{{ $t('versions.elementsAdded', { count: diff.elementsAdded.length }) }}
|
||||
</h4>
|
||||
<UCard v-for="(element, index) in diff.elementsAdded" :key="`added-${index}`" variant="subtle">
|
||||
<div class="flex items-start gap-2">
|
||||
<UBadge color="success" variant="subtle">+</UBadge>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ element.title || 'Ohne Titel' }}</div>
|
||||
<div class="text-sm text-gray-500">Typ: {{ element.type }} · Sektion: {{ element.sectionTitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
<div v-else-if="valueDiff && hasChanges" class="space-y-4">
|
||||
<!-- Summary Alert -->
|
||||
<UAlert
|
||||
:title="$t('versions.changesSummary', { count: totalChanges })"
|
||||
:icon="totalChanges > 0 ? 'i-lucide-file-diff' : 'i-lucide-check'"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
/>
|
||||
|
||||
<div v-if="diff.elementsRemoved.length > 0" class="space-y-2">
|
||||
<h4 class="font-semibold text-error flex items-center gap-2">
|
||||
<UIcon name="i-lucide-minus-circle" />
|
||||
{{ $t('versions.elementsRemoved', { count: diff.elementsRemoved.length }) }}
|
||||
</h4>
|
||||
<UCard v-for="(element, index) in diff.elementsRemoved" :key="`removed-${index}`" variant="subtle">
|
||||
<div class="flex items-start gap-2">
|
||||
<UBadge color="error" variant="subtle">-</UBadge>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ element.title || $t('versions.elementWithoutTitle') }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ $t('common.type') }}: {{ element.type }} · {{ $t('versions.elementIn') }}
|
||||
{{ element.sectionTitle }}
|
||||
<!-- Changes grouped by section -->
|
||||
<UAccordion
|
||||
v-if="sectionChanges.length > 0"
|
||||
:items="accordionItems"
|
||||
type="multiple"
|
||||
:default-value="accordionItems.map((_, i) => String(i))"
|
||||
>
|
||||
<template #body="{ item }">
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(change, changeIdx) in item.changes"
|
||||
:key="changeIdx"
|
||||
class="rounded-lg border border-default bg-elevated/50 p-3"
|
||||
>
|
||||
<!-- Element title with type indicator -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<UIcon v-if="change.elementType === 'TABLE'" name="i-lucide-table" class="h-4 w-4 text-muted" />
|
||||
<span class="font-medium text-sm">{{
|
||||
change.elementTitle || $t('versions.elementWithoutTitle')
|
||||
}}</span>
|
||||
|
||||
<!-- Change type badge -->
|
||||
<UBadge v-if="isNewAnswer(change)" color="success" variant="subtle" size="xs">
|
||||
{{ $t('versions.newAnswer') }}
|
||||
</UBadge>
|
||||
<UBadge v-else-if="isClearedAnswer(change)" color="warning" variant="subtle" size="xs">
|
||||
{{ $t('versions.clearedAnswer') }}
|
||||
</UBadge>
|
||||
<UBadge v-else color="info" variant="subtle" size="xs">
|
||||
{{ $t('versions.changedAnswer') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-if="diff.elementsModified.length > 0" class="space-y-2">
|
||||
<h4 class="font-semibold text-warning flex items-center gap-2">
|
||||
<UIcon name="i-lucide-pencil" />
|
||||
{{ $t('versions.elementsModified', { count: diff.elementsModified.length }) }}
|
||||
</h4>
|
||||
<UCard v-for="(element, index) in diff.elementsModified" :key="`modified-${index}`" variant="subtle">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<UBadge color="warning" variant="subtle">~</UBadge>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ $t('versions.elementIn') }} {{ element.sectionTitle }}</div>
|
||||
<!-- Table diff display -->
|
||||
<div v-if="change.elementType === 'TABLE' && change.tableDiff" class="mt-3">
|
||||
<!-- Table summary -->
|
||||
<div class="flex items-center gap-4 text-xs text-muted mb-2">
|
||||
<span v-if="change.tableDiff.addedCount > 0" class="text-success">
|
||||
+{{ change.tableDiff.addedCount }} {{ $t('versions.tableRowsAdded') }}
|
||||
</span>
|
||||
<span v-if="change.tableDiff.removedCount > 0" class="text-error">
|
||||
-{{ change.tableDiff.removedCount }} {{ $t('versions.tableRowsRemoved') }}
|
||||
</span>
|
||||
<span v-if="change.tableDiff.modifiedCount > 0" class="text-warning">
|
||||
~{{ change.tableDiff.modifiedCount }} {{ $t('versions.tableRowsModified') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
element.optionsAdded.length > 0 ||
|
||||
element.optionsRemoved.length > 0 ||
|
||||
element.optionsModified.length > 0
|
||||
"
|
||||
class="mt-3 pl-4 border-l-2 border-gray-200 space-y-2"
|
||||
>
|
||||
<div v-if="element.optionsAdded.length > 0">
|
||||
<div class="text-sm font-medium text-success">
|
||||
{{ $t('versions.optionsAdded', { count: element.optionsAdded.length }) }}:
|
||||
</div>
|
||||
<div
|
||||
v-for="(option, optIdx) in element.optionsAdded"
|
||||
:key="`opt-add-${optIdx}`"
|
||||
class="text-sm text-gray-700 ml-2"
|
||||
>
|
||||
<UBadge color="success" size="xs" class="mr-1">+</UBadge>
|
||||
{{ option.label }} ({{ option.value }})
|
||||
</div>
|
||||
<!-- Scrollable table container -->
|
||||
<div class="overflow-x-auto border border-default rounded-lg">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="bg-elevated">
|
||||
<tr>
|
||||
<th
|
||||
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap left-0 bg-elevated z-10"
|
||||
>
|
||||
#
|
||||
</th>
|
||||
<th
|
||||
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap left-8 bg-elevated z-10"
|
||||
>
|
||||
{{ $t('versions.tableStatus') }}
|
||||
</th>
|
||||
<th
|
||||
v-for="col in change.tableDiff.columns"
|
||||
:key="col"
|
||||
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap"
|
||||
>
|
||||
{{ col }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-default">
|
||||
<template v-for="row in change.tableDiff.rows" :key="row.rowIndex">
|
||||
<!-- Show only changed rows (added, removed, modified) -->
|
||||
<tr v-if="row.changeType !== 'unchanged'" :class="getRowClass(row.changeType)">
|
||||
<td
|
||||
class="px-3 py-2 text-muted whitespace-nowrap left-0 z-10"
|
||||
:class="getRowBgClass(row.changeType)"
|
||||
>
|
||||
{{ row.rowIndex + 1 }}
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap left-8 z-10" :class="getRowBgClass(row.changeType)">
|
||||
<UBadge :color="getStatusBadgeColor(row.changeType)" variant="subtle" size="xs">
|
||||
{{ getStatusLabel(row.changeType) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td v-for="col in change.tableDiff.columns" :key="col" class="px-3 py-2 whitespace-nowrap">
|
||||
<template v-if="row.changeType === 'added'">
|
||||
<span class="text-success">{{ row.currentValues[col] || '-' }}</span>
|
||||
</template>
|
||||
<template v-else-if="row.changeType === 'removed'">
|
||||
<span class="text-error line-through">{{ row.previousValues[col] || '-' }}</span>
|
||||
</template>
|
||||
<template v-else-if="row.changeType === 'modified'">
|
||||
<div v-if="row.previousValues[col] !== row.currentValues[col]" class="space-y-0.5">
|
||||
<div class="text-error line-through text-[10px]">
|
||||
{{ row.previousValues[col] || '-' }}
|
||||
</div>
|
||||
<div class="text-success">{{ row.currentValues[col] || '-' }}</div>
|
||||
</div>
|
||||
<span v-else class="text-muted">{{ row.currentValues[col] || '-' }}</span>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Non-table Before/After display -->
|
||||
<div v-else class="space-y-2 text-sm">
|
||||
<!-- Previous value -->
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex items-center gap-1.5 text-muted min-w-[60px] shrink-0">
|
||||
<UIcon name="i-lucide-circle" class="h-3 w-3 text-muted" />
|
||||
<span>{{ $t('versions.before') }}:</span>
|
||||
</div>
|
||||
<span :class="change.previousLabel ? 'text-default' : 'text-muted italic'">
|
||||
{{ change.previousLabel || $t('versions.noValue') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="element.optionsRemoved.length > 0">
|
||||
<div class="text-sm font-medium text-error">
|
||||
{{ $t('versions.optionsRemoved', { count: element.optionsRemoved.length }) }}:
|
||||
</div>
|
||||
<div
|
||||
v-for="(option, optIdx) in element.optionsRemoved"
|
||||
:key="`opt-rem-${optIdx}`"
|
||||
class="text-sm text-gray-700 ml-2"
|
||||
>
|
||||
<UBadge color="error" size="xs" class="mr-1">-</UBadge>
|
||||
{{ option.label }} ({{ option.value }})
|
||||
</div>
|
||||
</div>
|
||||
<!-- Arrow indicator -->
|
||||
<div class="flex items-center gap-2 pl-1">
|
||||
<UIcon name="i-lucide-arrow-down" class="h-3 w-3 text-muted" />
|
||||
</div>
|
||||
|
||||
<div v-if="element.optionsModified.length > 0">
|
||||
<div class="text-sm font-medium text-warning">
|
||||
{{ $t('versions.optionsModified', { count: element.optionsModified.length }) }}:
|
||||
</div>
|
||||
<div
|
||||
v-for="(option, optIdx) in element.optionsModified"
|
||||
:key="`opt-mod-${optIdx}`"
|
||||
class="text-sm text-gray-700 ml-2"
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium">{{ option.value }}:</span>
|
||||
<span class="text-error line-through ml-1">{{ option.labelChanged.from }}</span>
|
||||
<UIcon name="i-lucide-arrow-right" class="mx-1 inline-block h-3 w-3" />
|
||||
<span class="text-success">{{ option.labelChanged.to }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Current value -->
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex items-center gap-1.5 text-primary min-w-[60px] shrink-0">
|
||||
<UIcon name="i-lucide-circle-dot" class="h-3 w-3" />
|
||||
<span>{{ $t('versions.after') }}:</span>
|
||||
</div>
|
||||
<span :class="change.currentLabel ? 'text-default font-medium' : 'text-muted italic'">
|
||||
{{ change.currentLabel || $t('versions.noValue') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</UAccordion>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8 text-gray-500">{{ $t('versions.noChanges') }}</div>
|
||||
<div v-else class="text-center py-8">
|
||||
<UIcon name="i-lucide-check-circle" class="h-12 w-12 text-success mx-auto mb-3" />
|
||||
<p class="text-muted">{{ $t('versions.noChanges') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
@@ -132,9 +181,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AccordionItem } from '@nuxt/ui'
|
||||
import type { ApplicationFormDto, ApplicationFormVersionDto } from '~~/.api-client'
|
||||
import type { FormDiff } from '~~/types/formDiff'
|
||||
import { compareApplicationForms } from '~/utils/formDiff'
|
||||
import type { FormValueDiff, ValueChange, SectionChanges, TableRowDiff } from '~~/types/formDiff'
|
||||
import { compareApplicationFormValues, groupChangesBySection } from '~/utils/formDiff'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -152,22 +202,117 @@ const { t: $t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const diff = ref<FormDiff | null>(null)
|
||||
const valueDiff = ref<FormValueDiff | null>(null)
|
||||
const versionData = ref<ApplicationFormVersionDto | null>(null)
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!diff.value) return false
|
||||
const sectionChanges = computed<SectionChanges[]>(() => {
|
||||
if (!valueDiff.value) return []
|
||||
return groupChangesBySection(valueDiff.value)
|
||||
})
|
||||
|
||||
const totalChanges = computed(() => {
|
||||
if (!valueDiff.value) return 0
|
||||
return (
|
||||
diff.value.elementsAdded.length > 0 ||
|
||||
diff.value.elementsRemoved.length > 0 ||
|
||||
diff.value.elementsModified.length > 0
|
||||
valueDiff.value.newAnswers.length + valueDiff.value.changedAnswers.length + valueDiff.value.clearedAnswers.length
|
||||
)
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => totalChanges.value > 0)
|
||||
|
||||
interface AccordionItemWithChanges extends AccordionItem {
|
||||
changes: ValueChange[]
|
||||
}
|
||||
|
||||
const accordionItems = computed<AccordionItemWithChanges[]>(() => {
|
||||
return sectionChanges.value.map((section, index) => ({
|
||||
label: `${section.sectionTitle} (${$t('versions.changesCount', { count: section.changes.length })})`,
|
||||
icon: 'i-lucide-folder',
|
||||
value: String(index),
|
||||
changes: section.changes
|
||||
}))
|
||||
})
|
||||
|
||||
function isNewAnswer(change: ValueChange): boolean {
|
||||
return !change.previousLabel && !!change.currentLabel
|
||||
}
|
||||
|
||||
function isClearedAnswer(change: ValueChange): boolean {
|
||||
return !!change.previousLabel && !change.currentLabel
|
||||
}
|
||||
|
||||
function getRowClass(changeType: TableRowDiff['changeType']): string {
|
||||
switch (changeType) {
|
||||
case 'added':
|
||||
return 'bg-success/5'
|
||||
case 'removed':
|
||||
return 'bg-error/5'
|
||||
case 'modified':
|
||||
return 'bg-warning/5'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getRowBgClass(changeType: TableRowDiff['changeType']): string {
|
||||
switch (changeType) {
|
||||
case 'added':
|
||||
return 'bg-success/10'
|
||||
case 'removed':
|
||||
return 'bg-error/10'
|
||||
case 'modified':
|
||||
return 'bg-warning/10'
|
||||
default:
|
||||
return 'bg-elevated'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadgeColor(changeType: TableRowDiff['changeType']): 'success' | 'error' | 'warning' | 'neutral' {
|
||||
switch (changeType) {
|
||||
case 'added':
|
||||
return 'success'
|
||||
case 'removed':
|
||||
return 'error'
|
||||
case 'modified':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(changeType: TableRowDiff['changeType']): string {
|
||||
switch (changeType) {
|
||||
case 'added':
|
||||
return $t('versions.rowAdded')
|
||||
case 'removed':
|
||||
return $t('versions.rowRemoved')
|
||||
case 'modified':
|
||||
return $t('versions.rowModified')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Track which version was loaded to detect when we need to reload
|
||||
const loadedVersionNumber = ref<number | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (isOpen && !versionData.value) {
|
||||
if (isOpen) {
|
||||
// Always load if version changed or no data loaded yet
|
||||
if (loadedVersionNumber.value !== props.versionNumber || !versionData.value) {
|
||||
await loadVersionAndCompare()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Also reload when version number changes while modal is open
|
||||
watch(
|
||||
() => props.versionNumber,
|
||||
async (newVersion, oldVersion) => {
|
||||
if (newVersion !== oldVersion && props.open) {
|
||||
await loadVersionAndCompare()
|
||||
}
|
||||
}
|
||||
@@ -176,21 +321,15 @@ watch(
|
||||
async function loadVersionAndCompare() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
valueDiff.value = null // Reset diff while loading
|
||||
try {
|
||||
versionData.value = await getVersion(props.applicationFormId, props.versionNumber)
|
||||
diff.value = compareApplicationForms(props.currentForm, versionData.value.snapshot)
|
||||
valueDiff.value = compareApplicationFormValues(props.currentForm, versionData.value.snapshot)
|
||||
loadedVersionNumber.value = props.versionNumber
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : $t('versions.unknownError')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.versionNumber,
|
||||
() => {
|
||||
versionData.value = null
|
||||
diff.value = null
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<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) {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user