Assessor_2.0/src/features/comparison/use-timeslot-comparison.ts

175 lines
9.7 KiB
TypeScript

import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { groupProjectImportFiles, type ScanImportGroup } from '../project-import/datahub-import-groups';
import type { TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
import type { TimeslotDomainComparison } from '../../comparison/timeslot-domain-comparison';
const api = new AssessorApiClient();
export interface ComparableTimeslot {
id: string;
date: string;
assessmentIds: string[];
scanRunIds: string[];
}
export type TimeslotComparisonState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'unavailable'; reason: 'single-timeslot' | 'no-earlier-timeslot' }
| { status: 'ready'; report: TimeslotComparisonReport };
/** The comparison the Compare tab last loaded for the timeslot open in Client Info; Work reads it for its facet. */
export interface PublishedComparison {
projectId: string;
/** Sorted assessment IDs of timeslot B, the open timeslot. */
targetAssessmentIds: string[];
baseline: ComparableTimeslot;
target: ComparableTimeslot;
report: TimeslotComparisonReport;
}
const key = (values: readonly string[]) => [...values].sort().join('\u0000');
const sameSet = (left: readonly string[], right: readonly string[]) => key(left) === key(right);
const listeners = new Set<() => void>();
let storeVersion = 0;
const notify = () => { storeVersion++; listeners.forEach(listener => listener()); };
const version = () => storeVersion;
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
// Timeslot A chosen per client and timeslot B, shared by the Compare tab and Work.
const baselineChoices = new Map<string, string>();
let published: PublishedComparison | undefined;
export function publishedComparison(): PublishedComparison | undefined { return published; }
/** The published comparison while it belongs to this client and open timeslot; never a comparison of another scope. */
export function usePublishedComparison(projectId: string | undefined, openAssessmentIds: readonly string[]): PublishedComparison | undefined {
const current = useSyncExternalStore(subscribe, publishedComparison, publishedComparison);
return current && current.projectId === projectId && sameSet(current.targetAssessmentIds, openAssessmentIds) ? current : undefined;
}
/** Test support. */
export function resetTimeslotComparisonRequestsForTests(): void {
reports.clear(); domainRequests.clear(); timeslotRequests.clear(); baselineChoices.clear(); published = undefined; notify();
}
function comparable(group: ScanImportGroup): ComparableTimeslot {
return { id: group.id, date: group.fileDate, assessmentIds: [...group.assessmentIds].sort(), scanRunIds: [...group.scanRunIds].sort() };
}
const timeslotRequests = new Map<string, Promise<ComparableTimeslot[]>>();
/** Oldest first, the order in which a comparison reads A before B. Reloaded for every newly opened scope. */
function loadTimeslots(projectId: string, scope: string): Promise<ComparableTimeslot[]> {
const requestKey = `${projectId}\u0000${scope}`;
let request = timeslotRequests.get(requestKey);
if (!request) {
request = Promise.all([
api.listProjectImportFiles(projectId), api.listProjectAssessmentHistory(projectId), api.listProjectGpoBackupStatus(projectId)
]).then(([files, history, gpo]) => groupProjectImportFiles(files, history.assessments, history.scanRuns, gpo.records)
.filter(group => group.assessmentIds.length)
.map(comparable)
.sort((left, right) => Date.parse(left.date) - Date.parse(right.date) || left.id.localeCompare(right.id)));
timeslotRequests.set(requestKey, request);
while (timeslotRequests.size > 8) timeslotRequests.delete(timeslotRequests.keys().next().value!);
request.catch(() => timeslotRequests.delete(requestKey));
}
return request;
}
const reports = new Map<string, Promise<TimeslotComparisonReport>>();
function requestReport(projectId: string, baseline: ComparableTimeslot, target: ComparableTimeslot) {
const reportKey = JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]);
let request = reports.get(reportKey);
if (!request) {
request = api.getTimeslotComparison<TimeslotComparisonReport>(projectId, baseline, target);
reports.set(reportKey, request);
while (reports.size > 8) reports.delete(reports.keys().next().value!);
request.catch(() => reports.delete(reportKey));
}
return request;
}
/**
* Timeslots of the open client and the explicit A/B report. B defaults to the timeslot open in Client
* Info, A to the timeslot directly before it; both stay selectable, A always before B. A report whose B is
* the open timeslot is published for the Work `Change` facet.
*/
export function useTimeslotComparison(projectId: string | undefined, openAssessmentIds: readonly string[]) {
const openKey = key(openAssessmentIds);
const [timeslots, setTimeslots] = useState<{ scope: string; items?: ComparableTimeslot[]; error?: string }>();
const [targetChoice, setTargetChoice] = useState<{ scope: string; id: string }>();
const [result, setResult] = useState<{ key: string; state: TimeslotComparisonState }>();
const scope = `${projectId}\u0000${openKey}`;
useSyncExternalStore(subscribe, version, version);
useEffect(() => {
if (!projectId) return;
let current = true;
loadTimeslots(projectId, openKey).then(items => { if (current) setTimeslots({ scope, items }); },
error => { if (current) setTimeslots({ scope, error: error instanceof Error ? error.message : 'Timeslots could not be loaded.' }); });
return () => { current = false; };
}, [scope]); // eslint-disable-line react-hooks/exhaustive-deps
const items = timeslots?.scope === scope ? timeslots.items : undefined;
const open = useMemo(() => items?.find(item => sameSet(item.assessmentIds, openAssessmentIds)), [items, openKey]); // eslint-disable-line react-hooks/exhaustive-deps
const target = items?.find(item => targetChoice?.scope === scope && item.id === targetChoice.id) ?? open ?? items?.[items.length - 1];
const earlier = items && target ? items.slice(0, items.indexOf(target)) : [];
const choiceKey = target ? JSON.stringify([projectId, target.id]) : '';
const baseline = earlier.find(item => item.id === baselineChoices.get(choiceKey)) ?? earlier[earlier.length - 1];
const requestKey = projectId && baseline && target ? JSON.stringify([projectId, baseline.id, target.id]) : '';
useEffect(() => {
if (!projectId || !baseline || !target) return;
let current = true;
requestReport(projectId, baseline, target).then(report => {
if (!current) return;
setResult({ key: requestKey, state: { status: 'ready', report } });
if (target === open) {
published = { projectId, targetAssessmentIds: [...target.assessmentIds], baseline, target, report };
notify();
}
}, error => { if (current) setResult({ key: requestKey, state: { status: 'error', message: error instanceof Error ? error.message : 'The comparison could not be loaded.' } }); });
return () => { current = false; };
}, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps
let state: TimeslotComparisonState;
if (timeslots?.scope === scope && timeslots.error) state = { status: 'error', message: timeslots.error };
else if (!items) state = { status: 'loading' };
else if (items.length < 2) state = { status: 'unavailable', reason: 'single-timeslot' };
else if (!baseline) state = { status: 'unavailable', reason: 'no-earlier-timeslot' };
else state = result?.key === requestKey ? result.state : { status: 'loading' };
return {
state, timeslots: items ?? [], baseline, target, earlier,
/** True while B is the timeslot open in Client Info, so Work shows the same records. */
targetIsOpen: Boolean(target && target === open),
selectBaseline: (id: string) => { if (choiceKey) { baselineChoices.set(choiceKey, id); notify(); } },
selectTarget: (id: string) => setTargetChoice({ scope, id })
};
}
const domainRequests = new Map<string, Promise<TimeslotDomainComparison>>();
export type DomainComparisonState = { status: 'loading' } | { status: 'error' } | { status: 'ready'; domains: TimeslotDomainComparison };
/** Security Domain values of the selected A/B pair; requested apart from the report because they read both Work datasets. */
export function useDomainComparison(projectId: string | undefined, baseline?: ComparableTimeslot, target?: ComparableTimeslot): DomainComparisonState {
const requestKey = projectId && baseline && target
? JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]) : '';
const [result, setResult] = useState<{ key: string; state: DomainComparisonState }>();
useEffect(() => {
if (!requestKey || !projectId || !baseline || !target) return;
let current = true;
let request = domainRequests.get(requestKey);
if (!request) {
request = api.getTimeslotDomainComparison<TimeslotDomainComparison>(projectId, baseline, target);
domainRequests.set(requestKey, request);
while (domainRequests.size > 8) domainRequests.delete(domainRequests.keys().next().value!);
request.catch(() => domainRequests.delete(requestKey));
}
request.then(domains => { if (current) setResult({ key: requestKey, state: { status: 'ready', domains } }); },
() => { if (current) setResult({ key: requestKey, state: { status: 'error' } }); });
return () => { current = false; };
}, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps
return result?.key === requestKey ? result.state : { status: 'loading' };
}