175 lines
9.7 KiB
TypeScript
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' };
|
|
}
|