Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 86 additions & 173 deletions frontend/src/components/assessment/ActivityForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ const attachmentRefreshKey = ref(0);
const formData = ref<Partial<ActivityRead>>({});
const originalData = ref<Partial<ActivityRead>>({}); // 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<ActivityRead | null>(null);
Expand Down Expand Up @@ -158,83 +173,78 @@ 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;

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 || [];
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 replacement: new activity, or same activity with no pending edits.
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 },
);
Expand Down Expand Up @@ -366,6 +376,7 @@ async function handleSave() {
updatePayload as any,
);

userDirty.value = false;
toast.success('Activity updated successfully');
emit('saved');
} catch (error) {
Expand Down Expand Up @@ -463,14 +474,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<string, unknown>)[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),
Expand All @@ -479,8 +489,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();
}

Expand All @@ -504,105 +515,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<string, any> = {};
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) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/assessment/ActivityGroupForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ async function save() {

<div class="flex items-center justify-between rounded-lg border p-4">
<div class="space-y-0.5">
<Label class="text-base text-foreground">Visible</Label>
<Label class="text-sm font-medium">Visible</Label>
</div>
<Switch
v-model="formData.visible"
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/utils/conflictUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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);
}
8 changes: 6 additions & 2 deletions frontend/src/views/AssessmentActivityView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});

Expand Down
Loading