@@ -130,6 +130,21 @@ const attachmentRefreshKey = ref(0);
130130const formData = ref <Partial <ActivityRead >>({});
131131const originalData = ref <Partial <ActivityRead >>({}); // Snapshot for 3-way merge
132132
133+ // Dirty flag flips true only when the user edits the form.
134+ // Programmatic updates (initial load, server sync, conflict merge) bracket their
135+ // mutations with isProgrammaticUpdate so the watch below ignores them.
136+ const userDirty = ref (false );
137+ const isProgrammaticUpdate = ref (false );
138+
139+ watch (
140+ formData ,
141+ () => {
142+ if (isProgrammaticUpdate .value ) return ;
143+ userDirty .value = true ;
144+ },
145+ { deep: true },
146+ );
147+
133148// Conflict resolution state
134149const showConflictDialog = ref (false );
135150const serverConflictVersion = ref <ActivityRead | null >(null );
@@ -158,83 +173,78 @@ const conflictDisplayLookups = computed(() => {
158173watch (
159174 () => props .activity ,
160175 (newVal ) => {
161- if (newVal ) {
162- // Smart Update: If we are already editing this activity, only update specific fields
163- // that might have changed externally (e.g. via modals) without overwriting user's unsaved text
164- if (formData .value .id === newVal .id ) {
165- // Update dynamic questions
166- if (formData .value .evaluation && newVal .evaluation ) {
167- const newQuestions =
168- newVal .evaluation .dynamic_questions || [];
169- const currentQuestions =
170- formData .value .evaluation .dynamic_questions || [];
171-
172- formData .value .evaluation .dynamic_questions =
173- newQuestions .map ((newQ : any ) => {
174- const existingQ = currentQuestions .find (
175- (oldQ : any ) =>
176- oldQ .evaluation_template_id ===
177- newQ .evaluation_template_id ,
178- );
179- if (existingQ ) {
180- return {
181- ... newQ ,
182- data: existingQ .data ,
183- evaluation_result:
184- existingQ .evaluation_result ,
185- };
186- }
187- return newQ ;
188- });
189- }
190-
191- // Update KB articles
192- formData .value .linked_knowledge_base_articles = JSON .parse (
193- JSON .stringify (newVal .linked_knowledge_base_articles || []),
176+ if (! newVal ) return ;
177+
178+ isProgrammaticUpdate .value = true ;
179+
180+ const sameActivity = formData .value .id === newVal .id ;
181+
182+ // Preserve in-flight user edits only when refreshing the same activity
183+ // and the user has actually edited something. Otherwise replace formData
184+ // wholesale so external changes (other users, server merges) show up.
185+ if (sameActivity && userDirty .value ) {
186+ if (formData .value .evaluation && newVal .evaluation ) {
187+ const newQuestions = newVal .evaluation .dynamic_questions || [];
188+ const currentQuestions =
189+ formData .value .evaluation .dynamic_questions || [];
190+
191+ formData .value .evaluation .dynamic_questions = newQuestions .map (
192+ (newQ : any ) => {
193+ const existingQ = currentQuestions .find (
194+ (oldQ : any ) =>
195+ oldQ .evaluation_template_id ===
196+ newQ .evaluation_template_id ,
197+ );
198+ if (existingQ ) {
199+ return {
200+ ... newQ ,
201+ data: existingQ .data ,
202+ evaluation_result: existingQ .evaluation_result ,
203+ };
204+ }
205+ return newQ ;
206+ },
194207 );
208+ }
195209
196- // Always sync updated_at so the next save sends the correct version
197- formData .value .updated_at = newVal .updated_at ;
198-
199- // Refresh original snapshot so conflict detection compares against the latest saved state
200- // We must apply the same frontend defaults to originalData so hasUnsavedChanges doesn't trigger falsely
201- const newOriginal = JSON .parse (JSON .stringify (newVal ));
202- newOriginal .logged = newOriginal .logged ?? false ;
203- newOriginal .alerted = newOriginal .alerted ?? false ;
204- newOriginal .prevented = newOriginal .prevented ?? false ;
205- newOriginal .stakeholder_notification_created =
206- newOriginal .stakeholder_notification_created ?? false ;
210+ formData .value .linked_knowledge_base_articles = JSON .parse (
211+ JSON .stringify (newVal .linked_knowledge_base_articles || []),
212+ );
213+ formData .value .updated_at = newVal .updated_at ;
207214
208- originalData .value = newOriginal ;
209- return ;
210- }
215+ originalData .value = JSON .parse (JSON .stringify (newVal ));
211216
212- // Full Initialization (Switching activities or first load)
213- formData .value = JSON .parse (JSON .stringify (newVal ));
214-
215- // Ensure arrays are initialized
216- formData .value .sources = formData .value .sources || [];
217- formData .value .targets = formData .value .targets || [];
218- formData .value .tools = formData .value .tools || [];
219- formData .value .tags = formData .value .tags || [];
220- formData .value .alert_sources = formData .value .alert_sources || [];
221- formData .value .prevention_sources =
222- formData .value .prevention_sources || [];
223- formData .value .stakeholder_notification_sources =
224- formData .value .stakeholder_notification_sources || [];
225- formData .value .log_sources = formData .value .log_sources || [];
226- // Ensure booleans are properly initialized (API might send null)
227- formData .value .logged = formData .value .logged ?? false ;
228- formData .value .alerted = formData .value .alerted ?? false ;
229- formData .value .prevented = formData .value .prevented ?? false ;
230- formData .value .stakeholder_notification_created =
231- formData .value .stakeholder_notification_created ?? false ;
232-
233- // Save original snapshot AFTER default values are applied, watchers run, and v-model normalizes
234217 nextTick (() => {
235- originalData .value = JSON . parse ( JSON . stringify ( formData . value )) ;
218+ isProgrammaticUpdate .value = false ;
236219 });
220+ return ;
237221 }
222+
223+ // Full replacement: new activity, or same activity with no pending edits.
224+ formData .value = JSON .parse (JSON .stringify (newVal ));
225+
226+ formData .value .sources = formData .value .sources || [];
227+ formData .value .targets = formData .value .targets || [];
228+ formData .value .tools = formData .value .tools || [];
229+ formData .value .tags = formData .value .tags || [];
230+ formData .value .alert_sources = formData .value .alert_sources || [];
231+ formData .value .prevention_sources =
232+ formData .value .prevention_sources || [];
233+ formData .value .stakeholder_notification_sources =
234+ formData .value .stakeholder_notification_sources || [];
235+ formData .value .log_sources = formData .value .log_sources || [];
236+ formData .value .logged = formData .value .logged ?? false ;
237+ formData .value .alerted = formData .value .alerted ?? false ;
238+ formData .value .prevented = formData .value .prevented ?? false ;
239+ formData .value .stakeholder_notification_created =
240+ formData .value .stakeholder_notification_created ?? false ;
241+
242+ // Wait for child v-model normalizations to settle before clearing the flag.
243+ nextTick (() => {
244+ originalData .value = JSON .parse (JSON .stringify (formData .value ));
245+ userDirty .value = false ;
246+ isProgrammaticUpdate .value = false ;
247+ });
238248 },
239249 { immediate: true , deep: true },
240250);
@@ -366,6 +376,7 @@ async function handleSave() {
366376 updatePayload as any ,
367377 );
368378
379+ userDirty .value = false ;
369380 toast .success (' Activity updated successfully' );
370381 emit (' saved' );
371382 } catch (error ) {
@@ -463,14 +474,13 @@ async function handleConflictResolved(
463474) {
464475 showConflictDialog .value = false ;
465476
466- // Apply merged scalar fields to formData
477+ isProgrammaticUpdate .value = true ;
478+
467479 for (const [key, value] of Object .entries (mergedData )) {
468480 (formData .value as Record <string , unknown >)[key ] = value ;
469481 }
470- // Update updated_at to the server's latest so the retry passes concurrency check
471482 formData .value .updated_at = newUpdatedAt ;
472483
473- // Update original snapshot to the server version so future saves work correctly
474484 if (serverConflictVersion .value ) {
475485 originalData .value = JSON .parse (
476486 JSON .stringify (serverConflictVersion .value ),
@@ -479,8 +489,9 @@ async function handleConflictResolved(
479489
480490 // Wait for Vue watchers (tag name sync in ActivityGeneralInfo) to settle
481491 await nextTick ();
492+ isProgrammaticUpdate .value = false ;
482493
483- // Retry save with merged data
494+ // Retry save with merged data — handleSave clears userDirty on success.
484495 await handleSave ();
485496}
486497
@@ -504,105 +515,7 @@ async function handleCloneActivity() {
504515 }
505516}
506517
507- // Unsaved changes detection
508- const hasUnsavedChanges = computed (() => {
509- if (! formData .value .id || ! originalData .value .id ) return false ;
510- if (formData .value .id !== originalData .value .id ) return false ;
511-
512- // Fields to ignore when comparing (volatile / externally updated)
513- const ignoreKeys = new Set ([
514- ' updated_at' ,
515- ' created_at' ,
516- ' linked_knowledge_base_articles' ,
517- ]);
518-
519- const ignoreEvalKeys = new Set ([
520- ' logged_evaluation' ,
521- ' alerted_evaluation' ,
522- ' prevented_evaluation' ,
523- ' stakeholder_notified_evaluation' ,
524- ' activity_coverage_score' ,
525- ]);
526-
527- const cleanAndSort = (obj : any , isRoot = false , isEval = false ): any => {
528- if (Array .isArray (obj )) {
529- const arr = obj
530- .map ((v ) => cleanAndSort (v , false , false ))
531- .filter ((v ) => v !== null && v !== undefined && v !== ' ' );
532- return arr .length > 0 ? arr : undefined ;
533- }
534- if (obj !== null && typeof obj === ' object' ) {
535- const sortedKeys = Object .keys (obj ).sort ();
536- const result: Record <string , any > = {};
537- for (const key of sortedKeys ) {
538- if (isRoot && ignoreKeys .has (key )) continue ;
539- if (isEval && ignoreEvalKeys .has (key )) continue ;
540-
541- let val = cleanAndSort (
542- obj [key ],
543- false ,
544- isRoot && key === ' evaluation' ,
545- );
546-
547- // Ignore auto-calculated strings entirely as they are purely derived
548- if (
549- typeof val === ' string' &&
550- val .endsWith (' (auto-calculated)' )
551- ) {
552- continue ;
553- }
554-
555- // Normalize ISO datetime strings so .000Z and Z match
556- if (
557- typeof val === ' string' &&
558- / ^ \d {4} -\d {2} -\d {2} T\d {2} :\d {2} :\d {2} (\. \d + )? (Z| [+-] \d {2} :\d {2} )$ / .test (
559- val ,
560- )
561- ) {
562- try {
563- const d = new Date (val );
564- if (! Number .isNaN (d .getTime ())) val = d .toISOString ();
565- } catch {
566- // ignore
567- }
568- }
569-
570- if (val !== null && val !== undefined && val !== ' ' ) {
571- if (Array .isArray (val ) && val .length === 0 ) continue ;
572- if (
573- typeof val === ' object' &&
574- Object .keys (val ).length === 0
575- )
576- continue ;
577- result [key ] = val ;
578- }
579- }
580- return Object .keys (result ).length > 0 ? result : undefined ;
581- }
582-
583- // Normalize root-level string if it's a date
584- if (
585- typeof obj === ' string' &&
586- / ^ \d {4} -\d {2} -\d {2} T\d {2} :\d {2} :\d {2} (\. \d + )? (Z| [+-] \d {2} :\d {2} )$ / .test (
587- obj ,
588- )
589- ) {
590- try {
591- const d = new Date (obj );
592- if (! Number .isNaN (d .getTime ())) return d .toISOString ();
593- } catch {
594- // ignore
595- }
596- }
597-
598- return obj ;
599- };
600-
601- return (
602- JSON .stringify (cleanAndSort (formData .value , true )) !==
603- JSON .stringify (cleanAndSort (originalData .value , true ))
604- );
605- });
518+ const hasUnsavedChanges = computed (() => userDirty .value );
606519
607520// Warn on browser tab close / refresh with unsaved changes
608521function handleBeforeUnload(e : BeforeUnloadEvent ) {
0 commit comments