Skip to content

Commit 5110696

Browse files
authored
Merge pull request #14 from CompassSecurity/fix/frontend/ui
Fix/frontend/UI
2 parents a11cdc5 + c4795e1 commit 5110696

4 files changed

Lines changed: 94 additions & 179 deletions

File tree

frontend/src/components/assessment/ActivityForm.vue

Lines changed: 86 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@ const attachmentRefreshKey = ref(0);
130130
const formData = ref<Partial<ActivityRead>>({});
131131
const 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
134149
const showConflictDialog = ref(false);
135150
const serverConflictVersion = ref<ActivityRead | null>(null);
@@ -158,83 +173,78 @@ const conflictDisplayLookups = computed(() => {
158173
watch(
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
608521
function handleBeforeUnload(e: BeforeUnloadEvent) {

frontend/src/components/assessment/ActivityGroupForm.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ async function save() {
125125

126126
<div class="flex items-center justify-between rounded-lg border p-4">
127127
<div class="space-y-0.5">
128-
<Label class="text-base text-foreground">Visible</Label>
128+
<Label class="text-sm font-medium">Visible</Label>
129129
</div>
130130
<Switch
131131
v-model="formData.visible"

frontend/src/utils/conflictUtils.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -494,8 +494,6 @@ export function formatFieldValue(
494494
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
495495
if (typeof value === 'string') {
496496
if (value === '') return '(empty)';
497-
// Truncate long text
498-
if (value.length > 120) return `${value.substring(0, 120)}…`;
499497
return value;
500498
}
501499
if (Array.isArray(value)) {
@@ -508,7 +506,7 @@ export function formatFieldValue(
508506
return value.join(', ');
509507
}
510508
if (typeof value === 'object') {
511-
return JSON.stringify(value).substring(0, 120);
509+
return JSON.stringify(value, null, 2);
512510
}
513511
return String(value);
514512
}

frontend/src/views/AssessmentActivityView.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,14 @@ watch(assessmentId, async (newId, oldId) => {
6969
}
7070
});
7171
72-
// Fetch fresh data if the user directly lands on an activity not loaded yet
72+
// Refresh the active activity whenever the route's activityId changes so
73+
// the form always shows fresh data (sidebar clicks don't otherwise hit the API).
7374
watch(activityId, async () => {
74-
if (assessmentId.value && activityId.value && !currentActivity.value) {
75+
if (!assessmentId.value || !activityId.value) return;
76+
if (!currentActivity.value) {
7577
await fetchActivities();
78+
} else {
79+
await store.refreshActivity(assessmentId.value, activityId.value);
7680
}
7781
});
7882

0 commit comments

Comments
 (0)