import { PublicFormItem } from './publicFormSnapshot'; export interface PublicFormSnapshot { title: string; description?: string; items: PublicFormItem[]; } export interface PublicFormChange { kind: 'added' | 'removed' | 'changed'; label: string; details: string[]; } interface Placed { item: PublicFormItem; parent: string | undefined; } const flatten = (items: PublicFormItem[] | undefined, parent?: string, into = new Map()) => { for (const item of items ?? []) { into.set(item.linkId, { item, parent }); flatten(item.item, item.linkId, into); } return into; }; const choiceLabel = (option: any): string => { const coding = option?.valueCoding; if (coding) return coding.display ?? coding.code ?? ''; const value = Object.keys(option ?? {}).find((key) => key.startsWith('value')); return value ? String(option[value]) : ''; }; const choices = (item: PublicFormItem) => (item.answerOption ?? []).map(choiceLabel).filter(Boolean); const describe = (item: PublicFormItem): string[] => { const details: string[] = []; const labels = choices(item); if (labels.length) details.push(`Choices: ${labels.join(' or ')}`); if (item.required) details.push('Required'); return details; }; const labelOf = (item: PublicFormItem) => item.text?.trim() || item.linkId; // Key order is not meaningful: the live copy read back from the server may list fields differently. const equal = (a: unknown, b: unknown): boolean => { if (a === b) return true; if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; if (Array.isArray(a) !== Array.isArray(b)) return false; const keys = new Set([...Object.keys(a), ...Object.keys(b)]); return [...keys].every((key) => equal((a as any)[key], (b as any)[key])); }; const same = (a: unknown, b: unknown) => equal(a ?? null, b ?? null); const differences = (live: Placed, draft: Placed, byId: Map): string[] => { const was = live.item; const now = draft.item; const details: string[] = []; if ((was.text ?? '') !== (now.text ?? '')) details.push(`Wording was "${labelOf(was)}"`); if (was.type !== now.type) details.push(`Answer type ${was.type} is now ${now.type}`); if (!!was.required !== !!now.required) details.push(now.required ? 'Now required' : 'No longer required'); if (!same(was.answerOption, now.answerOption)) { const labels = choices(now); if (!labels.length) details.push('Choices removed'); else if (same(choices(was), labels)) details.push('Choice codes have changed; the wording has not'); else details.push(`Choices are now ${labels.join(' or ')}`); } if (!same(was.enableWhen, now.enableWhen)) details.push('When it shows has changed'); if ((was.columnSpan ?? 0) !== (now.columnSpan ?? 0)) details.push('Width has changed'); if (live.parent !== draft.parent) { const group = draft.parent ? byId.get(draft.parent) : undefined; details.push(group ? `Moved into ${labelOf(group.item)}` : 'Moved out of its group'); } return details; }; // Compares by linkId, so a question keeps its identity when it is reworded or moved. export function describePublicFormChanges(live: PublicFormSnapshot, draft: PublicFormSnapshot): PublicFormChange[] { const changes: PublicFormChange[] = []; if ((live.title ?? '') !== (draft.title ?? '')) { changes.push({ kind: 'changed', label: 'Form name', details: [`Was "${live.title}", now "${draft.title}"`] }); } if ((live.description ?? '') !== (draft.description ?? '')) { const details = !live.description ? ['Added'] : !draft.description ? ['Removed'] : ['Reworded']; changes.push({ kind: 'changed', label: 'Introduction', details }); } const was = flatten(live.items); const now = flatten(draft.items); for (const [linkId, placed] of now) { const before = was.get(linkId); if (!before) { changes.push({ kind: 'added', label: labelOf(placed.item), details: describe(placed.item) }); continue; } const details = differences(before, placed, now); if (details.length) changes.push({ kind: 'changed', label: labelOf(placed.item), details }); } for (const [linkId, placed] of was) { if (!now.has(linkId)) changes.push({ kind: 'removed', label: labelOf(placed.item), details: [] }); } for (const parent of new Set([undefined, ...now.keys()])) { const stays = (id: string) => was.has(id) && now.has(id) && was.get(id)!.parent === parent && now.get(id)!.parent === parent; const liveOrder = [...was.keys()].filter(stays); const draftOrder = [...now.keys()].filter(stays); if (same(liveOrder, draftOrder)) continue; const group = parent ? now.get(parent) : undefined; const label = group ? `Question order in ${labelOf(group.item)}` : 'Question order'; changes.push({ kind: 'changed', label, details: ['Questions appear in a new order'] }); } if (!changes.length && !same(live.items, draft.items)) { changes.push({ kind: 'changed', label: 'Form details', details: ['Other published settings have changed'] }); } return changes; }