From 86fd5fd861e89e4165748fde7f053c08134662e2 Mon Sep 17 00:00:00 2001 From: fxai Date: Tue, 5 May 2026 10:46:48 +0200 Subject: [PATCH 1/5] fix:change font size in group form --- frontend/src/components/assessment/ActivityGroupForm.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/assessment/ActivityGroupForm.vue b/frontend/src/components/assessment/ActivityGroupForm.vue index 611ff1a..437160c 100644 --- a/frontend/src/components/assessment/ActivityGroupForm.vue +++ b/frontend/src/components/assessment/ActivityGroupForm.vue @@ -125,7 +125,7 @@ async function save() {
- +
Date: Tue, 5 May 2026 10:57:33 +0200 Subject: [PATCH 2/5] fix: state based discard function instead of diff based --- .../components/assessment/ActivityForm.vue | 258 ++++++------------ 1 file changed, 85 insertions(+), 173 deletions(-) diff --git a/frontend/src/components/assessment/ActivityForm.vue b/frontend/src/components/assessment/ActivityForm.vue index e8c1643..f30475d 100644 --- a/frontend/src/components/assessment/ActivityForm.vue +++ b/frontend/src/components/assessment/ActivityForm.vue @@ -130,6 +130,21 @@ const attachmentRefreshKey = ref(0); const formData = ref>({}); const originalData = ref>({}); // Snapshot for 3-way merge +// Dirty flag flips true only when the user edits the form. +// Programmatic updates (initial load, server sync, conflict merge) bracket their +// mutations with isProgrammaticUpdate so the watch below ignores them. +const userDirty = ref(false); +const isProgrammaticUpdate = ref(false); + +watch( + formData, + () => { + if (isProgrammaticUpdate.value) return; + userDirty.value = true; + }, + { deep: true }, +); + // Conflict resolution state const showConflictDialog = ref(false); const serverConflictVersion = ref(null); @@ -158,83 +173,77 @@ const conflictDisplayLookups = computed(() => { watch( () => props.activity, (newVal) => { - if (newVal) { - // Smart Update: If we are already editing this activity, only update specific fields - // that might have changed externally (e.g. via modals) without overwriting user's unsaved text - if (formData.value.id === newVal.id) { - // Update dynamic questions - if (formData.value.evaluation && newVal.evaluation) { - const newQuestions = - newVal.evaluation.dynamic_questions || []; - const currentQuestions = - formData.value.evaluation.dynamic_questions || []; - - formData.value.evaluation.dynamic_questions = - newQuestions.map((newQ: any) => { - const existingQ = currentQuestions.find( - (oldQ: any) => - oldQ.evaluation_template_id === - newQ.evaluation_template_id, - ); - if (existingQ) { - return { - ...newQ, - data: existingQ.data, - evaluation_result: - existingQ.evaluation_result, - }; - } - return newQ; - }); - } - - // Update KB articles - formData.value.linked_knowledge_base_articles = JSON.parse( - JSON.stringify(newVal.linked_knowledge_base_articles || []), + if (!newVal) return; + + isProgrammaticUpdate.value = true; + + if (formData.value.id === newVal.id) { + // Smart Update: another user (or our own save) refreshed the activity. + // Sync only fields that aren't user-edited in-place to avoid clobbering + // unsaved text. originalData still tracks the latest server state for 3-way merge. + if (formData.value.evaluation && newVal.evaluation) { + const newQuestions = + newVal.evaluation.dynamic_questions || []; + const currentQuestions = + formData.value.evaluation.dynamic_questions || []; + + formData.value.evaluation.dynamic_questions = newQuestions.map( + (newQ: any) => { + const existingQ = currentQuestions.find( + (oldQ: any) => + oldQ.evaluation_template_id === + newQ.evaluation_template_id, + ); + if (existingQ) { + return { + ...newQ, + data: existingQ.data, + evaluation_result: existingQ.evaluation_result, + }; + } + return newQ; + }, ); + } - // Always sync updated_at so the next save sends the correct version - formData.value.updated_at = newVal.updated_at; - - // Refresh original snapshot so conflict detection compares against the latest saved state - // We must apply the same frontend defaults to originalData so hasUnsavedChanges doesn't trigger falsely - const newOriginal = JSON.parse(JSON.stringify(newVal)); - newOriginal.logged = newOriginal.logged ?? false; - newOriginal.alerted = newOriginal.alerted ?? false; - newOriginal.prevented = newOriginal.prevented ?? false; - newOriginal.stakeholder_notification_created = - newOriginal.stakeholder_notification_created ?? false; + formData.value.linked_knowledge_base_articles = JSON.parse( + JSON.stringify(newVal.linked_knowledge_base_articles || []), + ); + formData.value.updated_at = newVal.updated_at; - originalData.value = newOriginal; - return; - } + originalData.value = JSON.parse(JSON.stringify(newVal)); - // Full Initialization (Switching activities or first load) - formData.value = JSON.parse(JSON.stringify(newVal)); - - // Ensure arrays are initialized - formData.value.sources = formData.value.sources || []; - formData.value.targets = formData.value.targets || []; - formData.value.tools = formData.value.tools || []; - formData.value.tags = formData.value.tags || []; - formData.value.alert_sources = formData.value.alert_sources || []; - formData.value.prevention_sources = - formData.value.prevention_sources || []; - formData.value.stakeholder_notification_sources = - formData.value.stakeholder_notification_sources || []; - formData.value.log_sources = formData.value.log_sources || []; - // Ensure booleans are properly initialized (API might send null) - formData.value.logged = formData.value.logged ?? false; - formData.value.alerted = formData.value.alerted ?? false; - formData.value.prevented = formData.value.prevented ?? false; - formData.value.stakeholder_notification_created = - formData.value.stakeholder_notification_created ?? false; - - // Save original snapshot AFTER default values are applied, watchers run, and v-model normalizes nextTick(() => { - originalData.value = JSON.parse(JSON.stringify(formData.value)); + isProgrammaticUpdate.value = false; }); + return; } + + // Full Initialization (switching activities or first load) + formData.value = JSON.parse(JSON.stringify(newVal)); + + formData.value.sources = formData.value.sources || []; + formData.value.targets = formData.value.targets || []; + formData.value.tools = formData.value.tools || []; + formData.value.tags = formData.value.tags || []; + formData.value.alert_sources = formData.value.alert_sources || []; + formData.value.prevention_sources = + formData.value.prevention_sources || []; + formData.value.stakeholder_notification_sources = + formData.value.stakeholder_notification_sources || []; + formData.value.log_sources = formData.value.log_sources || []; + formData.value.logged = formData.value.logged ?? false; + formData.value.alerted = formData.value.alerted ?? false; + formData.value.prevented = formData.value.prevented ?? false; + formData.value.stakeholder_notification_created = + formData.value.stakeholder_notification_created ?? false; + + // Wait for child v-model normalizations to settle before clearing the flag. + nextTick(() => { + originalData.value = JSON.parse(JSON.stringify(formData.value)); + userDirty.value = false; + isProgrammaticUpdate.value = false; + }); }, { immediate: true, deep: true }, ); @@ -366,6 +375,7 @@ async function handleSave() { updatePayload as any, ); + userDirty.value = false; toast.success('Activity updated successfully'); emit('saved'); } catch (error) { @@ -463,14 +473,13 @@ async function handleConflictResolved( ) { showConflictDialog.value = false; - // Apply merged scalar fields to formData + isProgrammaticUpdate.value = true; + for (const [key, value] of Object.entries(mergedData)) { (formData.value as Record)[key] = value; } - // Update updated_at to the server's latest so the retry passes concurrency check formData.value.updated_at = newUpdatedAt; - // Update original snapshot to the server version so future saves work correctly if (serverConflictVersion.value) { originalData.value = JSON.parse( JSON.stringify(serverConflictVersion.value), @@ -479,8 +488,9 @@ async function handleConflictResolved( // Wait for Vue watchers (tag name sync in ActivityGeneralInfo) to settle await nextTick(); + isProgrammaticUpdate.value = false; - // Retry save with merged data + // Retry save with merged data — handleSave clears userDirty on success. await handleSave(); } @@ -504,105 +514,7 @@ async function handleCloneActivity() { } } -// Unsaved changes detection -const hasUnsavedChanges = computed(() => { - if (!formData.value.id || !originalData.value.id) return false; - if (formData.value.id !== originalData.value.id) return false; - - // Fields to ignore when comparing (volatile / externally updated) - const ignoreKeys = new Set([ - 'updated_at', - 'created_at', - 'linked_knowledge_base_articles', - ]); - - const ignoreEvalKeys = new Set([ - 'logged_evaluation', - 'alerted_evaluation', - 'prevented_evaluation', - 'stakeholder_notified_evaluation', - 'activity_coverage_score', - ]); - - const cleanAndSort = (obj: any, isRoot = false, isEval = false): any => { - if (Array.isArray(obj)) { - const arr = obj - .map((v) => cleanAndSort(v, false, false)) - .filter((v) => v !== null && v !== undefined && v !== ''); - return arr.length > 0 ? arr : undefined; - } - if (obj !== null && typeof obj === 'object') { - const sortedKeys = Object.keys(obj).sort(); - const result: Record = {}; - for (const key of sortedKeys) { - if (isRoot && ignoreKeys.has(key)) continue; - if (isEval && ignoreEvalKeys.has(key)) continue; - - let val = cleanAndSort( - obj[key], - false, - isRoot && key === 'evaluation', - ); - - // Ignore auto-calculated strings entirely as they are purely derived - if ( - typeof val === 'string' && - val.endsWith('(auto-calculated)') - ) { - continue; - } - - // Normalize ISO datetime strings so .000Z and Z match - if ( - typeof val === 'string' && - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test( - val, - ) - ) { - try { - const d = new Date(val); - if (!Number.isNaN(d.getTime())) val = d.toISOString(); - } catch { - // ignore - } - } - - if (val !== null && val !== undefined && val !== '') { - if (Array.isArray(val) && val.length === 0) continue; - if ( - typeof val === 'object' && - Object.keys(val).length === 0 - ) - continue; - result[key] = val; - } - } - return Object.keys(result).length > 0 ? result : undefined; - } - - // Normalize root-level string if it's a date - if ( - typeof obj === 'string' && - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test( - obj, - ) - ) { - try { - const d = new Date(obj); - if (!Number.isNaN(d.getTime())) return d.toISOString(); - } catch { - // ignore - } - } - - return obj; - }; - - return ( - JSON.stringify(cleanAndSort(formData.value, true)) !== - JSON.stringify(cleanAndSort(originalData.value, true)) - ); -}); +const hasUnsavedChanges = computed(() => userDirty.value); // Warn on browser tab close / refresh with unsaved changes function handleBeforeUnload(e: BeforeUnloadEvent) { From 5539842206ebf6b351d803a6a4a67c814d052af1 Mon Sep 17 00:00:00 2001 From: fxai Date: Tue, 5 May 2026 11:02:22 +0200 Subject: [PATCH 3/5] fix: reload activity by sidebar navigation --- frontend/src/components/assessment/ActivityForm.vue | 12 +++++++----- frontend/src/views/AssessmentActivityView.vue | 8 ++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/assessment/ActivityForm.vue b/frontend/src/components/assessment/ActivityForm.vue index f30475d..e277114 100644 --- a/frontend/src/components/assessment/ActivityForm.vue +++ b/frontend/src/components/assessment/ActivityForm.vue @@ -177,10 +177,12 @@ watch( isProgrammaticUpdate.value = true; - if (formData.value.id === newVal.id) { - // Smart Update: another user (or our own save) refreshed the activity. - // Sync only fields that aren't user-edited in-place to avoid clobbering - // unsaved text. originalData still tracks the latest server state for 3-way merge. + const sameActivity = formData.value.id === newVal.id; + + // Preserve in-flight user edits only when refreshing the same activity + // and the user has actually edited something. Otherwise replace formData + // wholesale so external changes (other users, server merges) show up. + if (sameActivity && userDirty.value) { if (formData.value.evaluation && newVal.evaluation) { const newQuestions = newVal.evaluation.dynamic_questions || []; @@ -219,7 +221,7 @@ watch( return; } - // Full Initialization (switching activities or first load) + // Full replacement: new activity, or same activity with no pending edits. formData.value = JSON.parse(JSON.stringify(newVal)); formData.value.sources = formData.value.sources || []; diff --git a/frontend/src/views/AssessmentActivityView.vue b/frontend/src/views/AssessmentActivityView.vue index 656da91..a9fd0ed 100644 --- a/frontend/src/views/AssessmentActivityView.vue +++ b/frontend/src/views/AssessmentActivityView.vue @@ -69,10 +69,14 @@ watch(assessmentId, async (newId, oldId) => { } }); -// Fetch fresh data if the user directly lands on an activity not loaded yet +// Refresh the active activity whenever the route's activityId changes so +// the form always shows fresh data (sidebar clicks don't otherwise hit the API). watch(activityId, async () => { - if (assessmentId.value && activityId.value && !currentActivity.value) { + if (!assessmentId.value || !activityId.value) return; + if (!currentActivity.value) { await fetchActivities(); + } else { + await store.refreshActivity(assessmentId.value, activityId.value); } }); From c3df1deeb155d584a6e22d93cc1292c73f0344f6 Mon Sep 17 00:00:00 2001 From: fxai Date: Tue, 5 May 2026 11:04:51 +0200 Subject: [PATCH 4/5] fix:remove truncation in conflict resolution --- frontend/src/utils/conflictUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/utils/conflictUtils.ts b/frontend/src/utils/conflictUtils.ts index fd3080b..7cd05df 100644 --- a/frontend/src/utils/conflictUtils.ts +++ b/frontend/src/utils/conflictUtils.ts @@ -494,8 +494,6 @@ export function formatFieldValue( if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (typeof value === 'string') { if (value === '') return '(empty)'; - // Truncate long text - if (value.length > 120) return `${value.substring(0, 120)}…`; return value; } if (Array.isArray(value)) { @@ -508,7 +506,7 @@ export function formatFieldValue( return value.join(', '); } if (typeof value === 'object') { - return JSON.stringify(value).substring(0, 120); + return JSON.stringify(value, null, 2); } return String(value); } From c4795e14d0644db0bbf58a6f6cf92a2b93564cf8 Mon Sep 17 00:00:00 2001 From: fxai Date: Tue, 5 May 2026 11:09:54 +0200 Subject: [PATCH 5/5] chore: linting --- frontend/src/components/assessment/ActivityForm.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/assessment/ActivityForm.vue b/frontend/src/components/assessment/ActivityForm.vue index e277114..0ff306a 100644 --- a/frontend/src/components/assessment/ActivityForm.vue +++ b/frontend/src/components/assessment/ActivityForm.vue @@ -184,8 +184,7 @@ watch( // wholesale so external changes (other users, server merges) show up. if (sameActivity && userDirty.value) { if (formData.value.evaluation && newVal.evaluation) { - const newQuestions = - newVal.evaluation.dynamic_questions || []; + const newQuestions = newVal.evaluation.dynamic_questions || []; const currentQuestions = formData.value.evaluation.dynamic_questions || [];