filegrc 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/model/index.js +8 -5
- package/model/v8.json +10947 -0
- package/package.json +1 -1
- package/src/cli.js +66 -1
- package/src/collection-scope.js +12 -0
- package/src/git.js +4 -4
- package/src/index.js +3 -0
- package/src/model-migration.js +160 -4
- package/src/obligations.js +15 -9
- package/src/policy-library/data-retention-schedule-v2.md +25 -0
- package/src/policy-library.js +52 -24
- package/src/program-amendment.js +222 -0
- package/src/program-path.js +12 -4
- package/src/program-readiness.js +25 -12
- package/src/requirement-mapping.js +61 -0
- package/src/retention.js +261 -0
- package/src/server.js +74 -1
- package/src/source-coverage.js +21 -7
- package/src/state.js +169 -1
- package/src/validate.js +81 -4
- package/src/web.js +320 -27
- package/src/workflow.js +26 -7
package/src/web.js
CHANGED
|
@@ -88,6 +88,7 @@ let onboardingSetupOnly = false;
|
|
|
88
88
|
let onboardingStillWorkingTimer = null;
|
|
89
89
|
let onboardingPendingDraft = false;
|
|
90
90
|
const resourceDetailRequests = new Map();
|
|
91
|
+
const stateSectionRequests = new Map();
|
|
91
92
|
let resourceGuideCleanup = null;
|
|
92
93
|
let repositorySyncPollTimer = null;
|
|
93
94
|
let repositorySyncPollInFlight = false;
|
|
@@ -101,20 +102,20 @@ start().catch((error) => {
|
|
|
101
102
|
|
|
102
103
|
async function start() {
|
|
103
104
|
const embedded = document.querySelector("#filegrc-data");
|
|
104
|
-
state = embedded ? JSON.parse(embedded.textContent) : await fetchJson("/api/state");
|
|
105
|
+
state = normalizeAppState(embedded ? JSON.parse(embedded.textContent) : await fetchJson("/api/state/bootstrap"));
|
|
105
106
|
window.addEventListener("hashchange", handleRouteChange);
|
|
106
107
|
window.addEventListener("resize", positionCurrentOnboarding);
|
|
107
108
|
window.addEventListener("scroll", positionCurrentOnboarding, true);
|
|
108
109
|
render();
|
|
110
|
+
loadStateForRoute();
|
|
109
111
|
scheduleRepositorySyncPoll();
|
|
110
|
-
|
|
111
|
-
queueMicrotask(requestOnboarding);
|
|
112
|
-
}
|
|
112
|
+
maybeRequestOnboarding();
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function handleRouteChange() {
|
|
116
116
|
document.querySelectorAll("dialog.editor[open], dialog.commit-dialog[open], dialog.alert-dialog[open]").forEach((dialog) => dialog.close());
|
|
117
117
|
render();
|
|
118
|
+
loadStateForRoute();
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
function render() {
|
|
@@ -128,7 +129,9 @@ function render() {
|
|
|
128
129
|
const nextNavigation = root.querySelector(".sidebar-nav");
|
|
129
130
|
if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
|
|
130
131
|
const main = root.querySelector("main");
|
|
131
|
-
|
|
132
|
+
const waitingFor = blockingStateSections(route).filter((section) => state.sections?.[section] !== "complete");
|
|
133
|
+
if (waitingFor.length) renderStateLoading(main, route, waitingFor);
|
|
134
|
+
else if (route.name === "home") renderHome(main);
|
|
132
135
|
else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
|
|
133
136
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
134
137
|
else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
|
|
@@ -140,6 +143,83 @@ function render() {
|
|
|
140
143
|
bindCommon();
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
function normalizeAppState(next) {
|
|
147
|
+
if (next.sections) return next;
|
|
148
|
+
return {
|
|
149
|
+
...next,
|
|
150
|
+
stateToken: null,
|
|
151
|
+
sections: {
|
|
152
|
+
repository: "complete",
|
|
153
|
+
program: "complete",
|
|
154
|
+
obligations: "complete",
|
|
155
|
+
audits: "complete",
|
|
156
|
+
workflow: "complete"
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function blockingStateSections(route) {
|
|
162
|
+
if (route.name === "home") return ["program", "obligations", "workflow"];
|
|
163
|
+
if (route.name === "repository") return ["repository"];
|
|
164
|
+
if (route.name === "obligations" || route.name === "stage" && route.stageId === "run") return ["program", "obligations"];
|
|
165
|
+
if (route.name === "audit-packet") return ["repository", "program", "obligations", "audits"];
|
|
166
|
+
if (route.name === "stage" && route.stageId === "audit") return ["program", "workflow", "audits"];
|
|
167
|
+
if (route.name === "stage") return ["program", "workflow"];
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function desiredStateSections(route) {
|
|
172
|
+
const sections = new Set(["repository", ...blockingStateSections(route)]);
|
|
173
|
+
if (route.name === "list" && (["requirement", "requirement-mapping"].includes(route.type) || state.model.collectionReviews?.[route.type])) sections.add("program");
|
|
174
|
+
if (route.name === "detail" && ["policy", "document", "training", "control", "component", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("program");
|
|
175
|
+
if (route.name === "detail" && ["policy", "document", "framework", "requirement", "commitment", "system", "component", "vendor", "information-type", "source-coverage", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("workflow");
|
|
176
|
+
if (route.name === "detail" && ["obligation", "action-item", "obligation-event"].includes(route.type)) sections.add("obligations");
|
|
177
|
+
if (route.name === "detail" && route.type === "audit") sections.add("audits");
|
|
178
|
+
return [...sections];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderStateLoading(main, route, sections) {
|
|
182
|
+
const failed = sections.find((section) => state.sections?.[section] === "error");
|
|
183
|
+
const title = failed ? "Could Not Finish Loading" : route.name === "home" ? "Loading Program Overview" : "Loading Current Workspace State";
|
|
184
|
+
const message = failed
|
|
185
|
+
? state.sectionErrors?.[failed] || "Reload the workspace and try again."
|
|
186
|
+
: "Records are ready. FileGRC is calculating the information needed for this page.";
|
|
187
|
+
main.innerHTML = '<div class="page"><section class="panel state-loading" role="status" aria-live="polite"><p class="kicker">' + (failed ? "Loading error" : "One moment") + '</p><h2>' + esc(title) + '</h2><p>' + esc(message) + '</p></section></div>';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function loadStateForRoute() {
|
|
191
|
+
const route = parseRoute();
|
|
192
|
+
for (const section of desiredStateSections(route)) loadStateSection(section);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function loadStateSection(section) {
|
|
196
|
+
if (!state.stateToken || state.sections?.[section] === "complete") return Promise.resolve();
|
|
197
|
+
if (stateSectionRequests.has(section)) return stateSectionRequests.get(section);
|
|
198
|
+
state.sections[section] = "loading";
|
|
199
|
+
const token = state.stateToken;
|
|
200
|
+
const request = fetchJson("/api/state/" + encodeURIComponent(section) + "?token=" + encodeURIComponent(token))
|
|
201
|
+
.then((result) => {
|
|
202
|
+
if (state.stateToken !== result.stateToken) return;
|
|
203
|
+
Object.assign(state, result.state);
|
|
204
|
+
state.sections[section] = "complete";
|
|
205
|
+
render();
|
|
206
|
+
loadStateForRoute();
|
|
207
|
+
scheduleRepositorySyncPoll();
|
|
208
|
+
if (section === "repository") maybeRequestOnboarding();
|
|
209
|
+
})
|
|
210
|
+
.catch((error) => {
|
|
211
|
+
if (state.stateToken !== token) return;
|
|
212
|
+
state.sections[section] = "error";
|
|
213
|
+
state.sectionErrors = { ...(state.sectionErrors || {}), [section]: error.message };
|
|
214
|
+
render();
|
|
215
|
+
})
|
|
216
|
+
.finally(() => {
|
|
217
|
+
if (stateSectionRequests.get(section) === request) stateSectionRequests.delete(section);
|
|
218
|
+
});
|
|
219
|
+
stateSectionRequests.set(section, request);
|
|
220
|
+
return request;
|
|
221
|
+
}
|
|
222
|
+
|
|
143
223
|
function parseRoute() {
|
|
144
224
|
const [path, query = ""] = location.hash.replace(/^#\/?/, "").split("?", 2);
|
|
145
225
|
let parts;
|
|
@@ -254,16 +334,33 @@ function topbar(route) {
|
|
|
254
334
|
: route.name === "list" && route.type === "document"
|
|
255
335
|
? documentListTitle(route.params)
|
|
256
336
|
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
257
|
-
const
|
|
337
|
+
const repositoryLoading = state.sections?.repository !== "complete";
|
|
338
|
+
const repositoryError = state.sections?.repository === "error";
|
|
339
|
+
const repositoryLabel = repositoryError
|
|
340
|
+
? "Git check failed"
|
|
341
|
+
: repositoryLoading
|
|
342
|
+
? "Checking Git"
|
|
343
|
+
: state.repository?.mode === "trunk"
|
|
258
344
|
? state.repository.label
|
|
259
345
|
: state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
|
|
260
|
-
const repositoryTone =
|
|
346
|
+
const repositoryTone = repositoryError
|
|
347
|
+
? "warn"
|
|
348
|
+
: repositoryLoading
|
|
349
|
+
? "neutral"
|
|
350
|
+
: state.repository?.mode === "trunk"
|
|
261
351
|
? repositoryStatusTone(state.repository.status)
|
|
262
352
|
: state.git.clean ? "good" : "warn";
|
|
263
|
-
|
|
353
|
+
const validationLoading = state.sections?.repository !== "complete";
|
|
354
|
+
const validationTone = repositoryError ? "warn" : validationLoading ? "neutral" : state.validation.ok ? "good" : "bad";
|
|
355
|
+
const validationLabel = repositoryError ? "Data check failed" : validationLoading ? "Checking data" : state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors";
|
|
356
|
+
return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><div class="topbar-status">' + topbarProgramReadiness() + '<label class="search topbar-search"><span aria-hidden="true">⌕</span><input data-global-search type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><a class="validation-chip" href="#/repository"><span class="status-dot ' + validationTone + '"></span>' + validationLabel + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
|
|
264
357
|
}
|
|
265
358
|
|
|
266
359
|
function topbarProgramReadiness() {
|
|
360
|
+
if (state.sections?.program !== "complete") {
|
|
361
|
+
const label = state.sections?.program === "loading" ? "…" : "View";
|
|
362
|
+
return '<a class="topbar-readiness" href="#/"><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + label + '</strong></span><span class="progress" aria-hidden="true"><span style="width:0%"></span></span></a>';
|
|
363
|
+
}
|
|
267
364
|
const progress = dashboardProgramReadiness(state.programReadiness);
|
|
268
365
|
const detail = progress.complete + " of " + progress.total + " readiness items complete";
|
|
269
366
|
return '<a class="topbar-readiness" href="' + nextProgramStageHref() + '" aria-label="' + esc("Program readiness: " + progress.percent + "%. " + detail + ". " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
|
|
@@ -369,7 +466,7 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
369
466
|
const progress = stageProgress(stage);
|
|
370
467
|
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
371
468
|
'<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 5</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(progress) + '</section>' +
|
|
372
|
-
(stage.id === "policies" ? renderPolicyApprovalGuidance() + renderPoliciesTable() : renderStagePageIndex(stage)) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
|
|
469
|
+
(stage.id === "policies" ? renderPolicyApprovalGuidance() + renderPoliciesTable() : renderStagePageIndex(stage)) + (stage.id === "controls" ? renderDocumentActivationAssessments() + renderPolicyActivationAssessments() + renderRetentionReadiness() + renderEvidenceReadiness() : "") + (stage.id === "audit" ? renderAuditDocumentActivationAssessments() : "") + '</div>';
|
|
373
470
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
374
471
|
main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
|
|
375
472
|
event.currentTarget.remove();
|
|
@@ -777,7 +874,8 @@ function recordWorkflowItems(type, id) {
|
|
|
777
874
|
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
778
875
|
return [
|
|
779
876
|
...(state.workflow?.findings || []),
|
|
780
|
-
...(state.workflow?.workItems || [])
|
|
877
|
+
...(state.workflow?.workItems || []),
|
|
878
|
+
...programReadinessWorkflowItems()
|
|
781
879
|
].filter((item) => (
|
|
782
880
|
activeStates.has(item.state)
|
|
783
881
|
&& (
|
|
@@ -792,6 +890,9 @@ function recordWorkflowItems(type, id) {
|
|
|
792
890
|
}
|
|
793
891
|
|
|
794
892
|
function recordWorkflowCell(type, entry) {
|
|
893
|
+
if (state.sections?.workflow !== "complete") {
|
|
894
|
+
return '<button class="text-button record-workflow-clear" type="button" data-load-workflow>Calculate action</button>';
|
|
895
|
+
}
|
|
795
896
|
const items = recordWorkflowItems(type, entry.record.id);
|
|
796
897
|
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
797
898
|
const item = items[0];
|
|
@@ -886,6 +987,14 @@ function workflowItemHref(item) {
|
|
|
886
987
|
...(item.actions || []).map((action) => action.command),
|
|
887
988
|
item.nextAction?.command
|
|
888
989
|
].filter(Boolean);
|
|
990
|
+
if (item.createResourceType && state.model.resources[item.createResourceType]) {
|
|
991
|
+
const params = new URLSearchParams({ new: "1" });
|
|
992
|
+
if (item.title) params.set("title", item.createResourceType === "commitment"
|
|
993
|
+
? item.title.replace(/^Record commitments from\s+/i, "Commitment from ")
|
|
994
|
+
: item.title.replace(/^Map commitments affected by\s+/i, "Mapping for "));
|
|
995
|
+
for (const id of item.sourceResourceIds || []) params.append("sourceResourceId", id);
|
|
996
|
+
return "#/resources/" + encodeURIComponent(item.createResourceType) + "?" + params;
|
|
997
|
+
}
|
|
889
998
|
const applicabilityCommand = commands.find((command) => command.includes(" review-applicability "));
|
|
890
999
|
const applicabilityType = applicabilityCommand?.match(/--type\s+([a-z0-9-]+)/)?.[1];
|
|
891
1000
|
if (
|
|
@@ -896,6 +1005,9 @@ function workflowItemHref(item) {
|
|
|
896
1005
|
) {
|
|
897
1006
|
return "#/resource/" + encodeURIComponent(applicabilityType) + "/" + encodeURIComponent(item.subject.id);
|
|
898
1007
|
}
|
|
1008
|
+
if (commands.some((command) => command.includes(" scaffold retention-schedule-item"))) {
|
|
1009
|
+
return retentionScheduleItemHref(item);
|
|
1010
|
+
}
|
|
899
1011
|
if (applicabilityType && state.model.resources[applicabilityType]) {
|
|
900
1012
|
return "#/resources/" + encodeURIComponent(applicabilityType) + "?review=1";
|
|
901
1013
|
}
|
|
@@ -946,6 +1058,21 @@ function workflowItemHref(item) {
|
|
|
946
1058
|
return stage ? "#/stage/" + stage : "";
|
|
947
1059
|
}
|
|
948
1060
|
|
|
1061
|
+
function retentionScheduleItemHref(item) {
|
|
1062
|
+
const params = new URLSearchParams({ new: "1" });
|
|
1063
|
+
if (item.informationTypeId) params.set("informationTypeId", item.informationTypeId);
|
|
1064
|
+
if (item.resourceId || item.subject?.id) params.set("scopeResourceId", item.resourceId || item.subject.id);
|
|
1065
|
+
const informationTypeTitle = String(item.title || "").replace(/^Decide retention for\s+/i, "").trim();
|
|
1066
|
+
if (informationTypeTitle && informationTypeTitle !== item.title) params.set("title", "Retention for " + informationTypeTitle);
|
|
1067
|
+
const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
|
|
1068
|
+
record.documentKind === "schedule"
|
|
1069
|
+
&& record.workflowScope !== "engagement"
|
|
1070
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
1071
|
+
));
|
|
1072
|
+
if (scheduleDocuments.length === 1) params.set("scheduleDocumentId", scheduleDocuments[0].record.id);
|
|
1073
|
+
return "#/resources/retention-schedule-item?" + params;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
949
1076
|
function workflowItemDetail(item) {
|
|
950
1077
|
if (item.dueOn) return "Due " + item.dueOn;
|
|
951
1078
|
if (item.availableOn) return "Available " + item.availableOn;
|
|
@@ -1020,6 +1147,59 @@ function renderEvidenceReadiness() {
|
|
|
1020
1147
|
return '<section class="evidence-map"><div class="evidence-map-head"><div><p class="kicker">Control implementation</p><h2>' + completeCount + ' of ' + items.length + ' evidence ' + (items.length === 1 ? "family" : "families") + ' ready</h2><p>Connect each Control to the ' + sourceLabel + ' that produce its evidence.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/' + sourceType + '">Review ' + sourceLabel + '</a><a class="button primary" href="#/resources/control">Review Controls</a></div></div>' + (cards || empty) + more + '</section>';
|
|
1021
1148
|
}
|
|
1022
1149
|
|
|
1150
|
+
function retentionReviewItems() {
|
|
1151
|
+
return (state.programReadiness?.stages || []).flatMap((stage) => (
|
|
1152
|
+
(stage.items || []).filter((item) => (
|
|
1153
|
+
item.id?.startsWith("retention-")
|
|
1154
|
+
|| item.id?.startsWith("requirement-mapping-")
|
|
1155
|
+
|| ["collection-review-information-type", "collection-review-retention-schedule-item"].includes(item.id)
|
|
1156
|
+
)).map((item) => ({ ...item, stage: stage.id }))
|
|
1157
|
+
));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function programReadinessWorkflowItems() {
|
|
1161
|
+
if (state.sections?.workflow === "complete") return [];
|
|
1162
|
+
return retentionReviewItems()
|
|
1163
|
+
.filter((item) => item.status === "action")
|
|
1164
|
+
.map((item) => ({
|
|
1165
|
+
...item,
|
|
1166
|
+
key: "program-readiness." + item.id,
|
|
1167
|
+
state: "ready",
|
|
1168
|
+
subject: item.resourceId && item.resourceType ? { type: item.resourceType, id: item.resourceId } : { type: item.resourceType || "unknown" }
|
|
1169
|
+
}));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function renderRetentionReadiness() {
|
|
1173
|
+
const items = retentionReviewItems();
|
|
1174
|
+
if (!items.length) return "";
|
|
1175
|
+
const actions = items.filter((item) => item.status === "action");
|
|
1176
|
+
const cards = actions.map((item) => {
|
|
1177
|
+
const ids = [...new Set([
|
|
1178
|
+
item.resourceId,
|
|
1179
|
+
item.informationTypeId,
|
|
1180
|
+
...(item.retentionScheduleItemIds || []),
|
|
1181
|
+
...(item.staleResourceIds || [])
|
|
1182
|
+
].filter(Boolean))];
|
|
1183
|
+
const references = ids.length
|
|
1184
|
+
? '<div class="evidence-map-references">' + ids.map((id) => formatReference(id)).join("") + '</div>'
|
|
1185
|
+
: "";
|
|
1186
|
+
const href = item.id?.startsWith("retention-use-")
|
|
1187
|
+
? retentionScheduleItemHref(item)
|
|
1188
|
+
: item.id?.startsWith("collection-review-")
|
|
1189
|
+
? '#/resources/' + encodeURIComponent(item.resourceType)
|
|
1190
|
+
: item.resourceId && item.resourceType
|
|
1191
|
+
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
1192
|
+
: '#/resources/' + encodeURIComponent(item.resourceType || "retention-schedule-item");
|
|
1193
|
+
return '<article class="evidence-map-card"><div class="evidence-map-card-head"><div><span class="badge warn">Review</span><h3><a href="' + href + '">' + esc(item.title) + '</a></h3></div></div><p>' + esc(item.message) + '</p>' + references + '</article>';
|
|
1194
|
+
});
|
|
1195
|
+
const visibleCount = 6;
|
|
1196
|
+
const visibleCards = cards.slice(0, visibleCount).join("");
|
|
1197
|
+
const moreCards = cards.length > visibleCount
|
|
1198
|
+
? '<details class="workflow-guidance-more retention-readiness-more"><summary>Show ' + (cards.length - visibleCount) + ' more retention and mapping items</summary><div class="policy-activation-grid workflow-findings-more">' + cards.slice(visibleCount).join("") + '</div></details>'
|
|
1199
|
+
: "";
|
|
1200
|
+
return '<section class="evidence-map retention-readiness"><div class="evidence-map-head"><div><p class="kicker">Information lifecycle</p><h2>' + (items.length - actions.length) + ' of ' + items.length + ' retention and mapping checks current</h2><p>Review Information Types, schedule coverage, and mappings here when source records or processing uses change.</p></div><div class="evidence-map-actions"><a class="button" href="#/resources/requirement-mapping">Review mappings</a><a class="button primary" href="#/resources/retention-schedule-item">Review schedule</a></div></div>' + (cards.length ? '<div class="policy-activation-grid">' + visibleCards + '</div>' + moreCards : '<article class="evidence-map-card complete"><span class="badge good">Current</span><p>Every retention decision and Requirement Mapping is bound to its current sources.</p></article>') + '</section>';
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1023
1203
|
function evidenceSourceCheckLabel(name) {
|
|
1024
1204
|
return ({
|
|
1025
1205
|
active: "activate source",
|
|
@@ -1122,7 +1302,8 @@ function stagePageItems(stage, destination) {
|
|
|
1122
1302
|
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready"]);
|
|
1123
1303
|
const items = [
|
|
1124
1304
|
...(state.workflow?.findings || []),
|
|
1125
|
-
...(state.workflow?.workItems || [])
|
|
1305
|
+
...(state.workflow?.workItems || []),
|
|
1306
|
+
...programReadinessWorkflowItems()
|
|
1126
1307
|
].filter((item) => (
|
|
1127
1308
|
activeStates.has(item.state)
|
|
1128
1309
|
&& (
|
|
@@ -2295,9 +2476,26 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
2295
2476
|
renderRows();
|
|
2296
2477
|
syncRoute();
|
|
2297
2478
|
main.querySelector("#new-resource")?.addEventListener("click", () => openEditor(type));
|
|
2479
|
+
main.querySelector("#record-rows").addEventListener("click", (event) => {
|
|
2480
|
+
if (event.target.closest("[data-load-workflow]")) loadStateSection("workflow");
|
|
2481
|
+
});
|
|
2298
2482
|
main.querySelector("#review-applicability")?.addEventListener("click", () => openApplicabilityReviewDialog(type, entries));
|
|
2299
2483
|
main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog(type));
|
|
2300
|
-
if (params.get("new") === "1" && !state.readOnly && !definition.singleton && resourceCreationAllowed(type))
|
|
2484
|
+
if (params.get("new") === "1" && !state.readOnly && !definition.singleton && resourceCreationAllowed(type)) {
|
|
2485
|
+
const relationshipSeed = params.getAll("sourceResourceId").length
|
|
2486
|
+
? { sourceResourceIds: params.getAll("sourceResourceId") }
|
|
2487
|
+
: {};
|
|
2488
|
+
const seed = type === "retention-schedule-item" ? {
|
|
2489
|
+
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
2490
|
+
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
2491
|
+
...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
|
|
2492
|
+
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
2493
|
+
} : {
|
|
2494
|
+
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
2495
|
+
...relationshipSeed
|
|
2496
|
+
};
|
|
2497
|
+
queueMicrotask(() => openEditor(type, null, { seed }));
|
|
2498
|
+
}
|
|
2301
2499
|
if (params.get("review") === "1" && !state.readOnly) {
|
|
2302
2500
|
queueMicrotask(() => main.querySelector("#review-applicability")?.click());
|
|
2303
2501
|
}
|
|
@@ -2960,6 +3158,12 @@ function rendererSettingsEntry() {
|
|
|
2960
3158
|
return state.resources.find(({ record }) => record.type === "renderer-settings");
|
|
2961
3159
|
}
|
|
2962
3160
|
|
|
3161
|
+
function maybeRequestOnboarding() {
|
|
3162
|
+
if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true && !initialSetupSystem()) {
|
|
3163
|
+
queueMicrotask(requestOnboarding);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
|
|
2963
3167
|
function requestOnboarding({ setupOnly = false } = {}) {
|
|
2964
3168
|
if (state.readOnly || onboardingDialog || !rendererSettingsEntry()) return;
|
|
2965
3169
|
if (parseRoute().name !== "home") {
|
|
@@ -3407,6 +3611,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3407
3611
|
...required,
|
|
3408
3612
|
...(definition.listFields || []),
|
|
3409
3613
|
...(definition.formFields || []),
|
|
3614
|
+
...Object.entries(fields).filter(([, field]) => field.relation || field.relationGroup).map(([name]) => name),
|
|
3410
3615
|
...Object.entries(fields).filter(([, field]) => field.requiredWhen).map(([name]) => name),
|
|
3411
3616
|
...oneOf
|
|
3412
3617
|
])].filter((name) => !["id", "type"].includes(name) && fields[name]);
|
|
@@ -3674,27 +3879,29 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
3674
3879
|
const requiredMark = required || field.requiredWhen || oneOfRequired
|
|
3675
3880
|
? '<span class="required-mark" ' + (required || oneOfActive ? "" : "hidden") + '>' + (oneOfRequired ? "One Required" : "Required") + '</span>'
|
|
3676
3881
|
: "";
|
|
3882
|
+
const relation = field.relation || state.model.relationGroups?.[field.relationGroup];
|
|
3883
|
+
const relationField = relation ? { ...field, relation } : field;
|
|
3677
3884
|
const help = name === "title"
|
|
3678
3885
|
? (editing ? "Renaming this record will not change its stable ID." : "A stable ID and file name will be generated from this value.")
|
|
3679
|
-
:
|
|
3886
|
+
: relation ? relationHelp(relationField)
|
|
3680
3887
|
: "";
|
|
3681
3888
|
let control;
|
|
3682
3889
|
if (field.managed) {
|
|
3683
3890
|
control = '<textarea readonly spellcheck="false" placeholder="Filled when approval is saved">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
|
|
3684
3891
|
return fieldWrap(name, "object", label, requiredMark, control, "Managed by filegrc from the exact companion Markdown revisions", false);
|
|
3685
3892
|
}
|
|
3686
|
-
if (
|
|
3687
|
-
const candidates = relationCandidates(
|
|
3893
|
+
if (relation && field.type === "array") {
|
|
3894
|
+
const candidates = relationCandidates(relationField, type, name);
|
|
3688
3895
|
control = candidates.length
|
|
3689
3896
|
? '<div class="checkbox-list">' + candidates.map(({ record }) => '<label><input type="checkbox" value="' + esc(record.id) + '" ' + ((value || []).includes(record.id) ? "checked" : "") + '><span>' + esc(record.title) + '<small>' + esc(state.model.resources[record.type].title) + '</small></span></label>').join("") + '</div>'
|
|
3690
|
-
: required ? '<select><option value="">No matching ' + esc(relationTypeLabel(
|
|
3897
|
+
: required ? '<select><option value="">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet.</div>';
|
|
3691
3898
|
return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
|
|
3692
3899
|
}
|
|
3693
|
-
if (
|
|
3694
|
-
const candidates = relationCandidates(
|
|
3900
|
+
if (relation) {
|
|
3901
|
+
const candidates = relationCandidates(relationField, type, name);
|
|
3695
3902
|
control = candidates.length
|
|
3696
|
-
? '<select><option value="">Select ' + esc(relationTypeLabel(
|
|
3697
|
-
: required ? '<select><option value="">No matching ' + esc(relationTypeLabel(
|
|
3903
|
+
? '<select><option value="">Select ' + esc(relationTypeLabel(relationField).toLowerCase()) + '</option>' + candidates.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (value === record.id ? "selected" : "") + '>' + esc(record.title) + ' · ' + esc(state.model.resources[record.type].title) + '</option>').join("") + '</select>'
|
|
3904
|
+
: required ? '<select><option value="">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(relationField, true).toLowerCase()) + ' yet.</div>';
|
|
3698
3905
|
return fieldWrap(name, "relation", label, requiredMark, control, help, required);
|
|
3699
3906
|
}
|
|
3700
3907
|
if (name === "classificationId") {
|
|
@@ -3730,6 +3937,13 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
3730
3937
|
control = '<div class="structured-object-fields" data-object-type="' + esc(field.objectType) + '">' + objectPropertyFields(schema, value || {}) + '</div>';
|
|
3731
3938
|
return fieldWrap(name, "structured-object", label, requiredMark, control, "Fill only the details that apply.", required);
|
|
3732
3939
|
}
|
|
3940
|
+
if (schema?.additionalProperties?.type === "string") {
|
|
3941
|
+
control = stringMapEditor(value || {}, name, name === "reviewedSourceRevisions");
|
|
3942
|
+
const mapHelp = name === "reviewedSourceRevisions"
|
|
3943
|
+
? "Bind the exact current JSON and Markdown revisions after reviewing every selected record."
|
|
3944
|
+
: "Add one named value per row.";
|
|
3945
|
+
return fieldWrap(name, "string-map", label, requiredMark, control, mapHelp, required);
|
|
3946
|
+
}
|
|
3733
3947
|
control = '<textarea spellcheck="false" placeholder="{ }">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
|
|
3734
3948
|
return fieldWrap(name, "object", label, requiredMark, control, "JSON object", required);
|
|
3735
3949
|
}
|
|
@@ -3824,10 +4038,11 @@ function objectArrayItem(schema, value = {}) {
|
|
|
3824
4038
|
return '<fieldset class="object-array-item"><legend>' + esc(humanize(schema?.title || "Relationship")) + '</legend><div class="structured-object-fields">' + objectPropertyFields(schema, value) + '</div><button type="button" class="text-button" data-remove-object-item>Remove</button></fieldset>';
|
|
3825
4039
|
}
|
|
3826
4040
|
|
|
3827
|
-
function stringMapEditor(value = {}, name = "item") {
|
|
4041
|
+
function stringMapEditor(value = {}, name = "item", bindCurrent = false) {
|
|
3828
4042
|
const entries = Object.entries(value);
|
|
3829
4043
|
const row = ([key = "", item = ""] = []) => '<div class="string-map-row"><input data-map-key value="' + esc(key) + '" placeholder="Name"><input data-map-value value="' + esc(item) + '" placeholder="Value"><button type="button" class="text-button" data-remove-string-map aria-label="Remove ' + esc(humanize(name).toLowerCase()) + '">Remove</button></div>';
|
|
3830
|
-
|
|
4044
|
+
const bind = bindCurrent ? '<button type="button" class="button" data-bind-review-revisions>Bind current revisions</button>' : "";
|
|
4045
|
+
return '<div class="string-map-editor"><div class="string-map-items">' + (entries.length ? entries.map(row).join("") : row()) + '</div><div class="string-map-actions">' + bind + '<button type="button" class="button" data-add-string-map>Add ' + esc(humanize(name).replace(/s$/, "").toLowerCase()) + '</button></div><template>' + row() + '</template></div>';
|
|
3831
4046
|
}
|
|
3832
4047
|
|
|
3833
4048
|
function wireStructuredObjectEditors(dialog) {
|
|
@@ -3844,6 +4059,17 @@ function wireStructuredObjectEditors(dialog) {
|
|
|
3844
4059
|
});
|
|
3845
4060
|
});
|
|
3846
4061
|
dialog.querySelector("form").addEventListener("click", (event) => {
|
|
4062
|
+
const bind = event.target.closest("[data-bind-review-revisions]");
|
|
4063
|
+
if (bind) {
|
|
4064
|
+
const previousLabel = bind.textContent;
|
|
4065
|
+
bindCurrentReviewRevisions(dialog, bind).catch((error) => {
|
|
4066
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
4067
|
+
}).finally(() => {
|
|
4068
|
+
bind.disabled = false;
|
|
4069
|
+
if (bind.textContent === "Binding…") bind.textContent = previousLabel;
|
|
4070
|
+
});
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
3847
4073
|
const add = event.target.closest("[data-add-string-map]");
|
|
3848
4074
|
if (add) {
|
|
3849
4075
|
const editor = add.closest(".string-map-editor");
|
|
@@ -3902,6 +4128,60 @@ function wireStructuredObjectEditors(dialog) {
|
|
|
3902
4128
|
});
|
|
3903
4129
|
}
|
|
3904
4130
|
|
|
4131
|
+
async function bindCurrentReviewRevisions(dialog, button) {
|
|
4132
|
+
const selected = Object.fromEntries(["sourceResourceIds", "targetResourceIds", "informationTypeIds", "scopeResourceIds"].map((name) => [
|
|
4133
|
+
name,
|
|
4134
|
+
[...dialog.querySelectorAll('[data-field-group="' + name + '"] input[type="checkbox"]:checked')].map((input) => input.value)
|
|
4135
|
+
]));
|
|
4136
|
+
const scheduleDocumentId = dialog.querySelector('[data-field-group="scheduleDocumentId"] select')?.value || "";
|
|
4137
|
+
const mapping = Boolean(dialog.querySelector('[data-field-group="targetResourceIds"]'));
|
|
4138
|
+
if (mapping && (!selected.sourceResourceIds.length || !selected.targetResourceIds.length)) {
|
|
4139
|
+
throw new Error("Select records on both sides of the Requirement Mapping before binding their current revisions.");
|
|
4140
|
+
}
|
|
4141
|
+
if (!mapping && (!scheduleDocumentId || !selected.informationTypeIds.length || !selected.scopeResourceIds.length)) {
|
|
4142
|
+
throw new Error("Select the retention schedule, Information Types, and operational scope before binding their current revisions.");
|
|
4143
|
+
}
|
|
4144
|
+
const ids = [...new Set(Object.values(selected).flat().concat(scheduleDocumentId, retentionUseReviewIds(dialog)).filter(Boolean))];
|
|
4145
|
+
button.disabled = true;
|
|
4146
|
+
button.textContent = "Binding…";
|
|
4147
|
+
const query = ids.map((id) => "id=" + encodeURIComponent(id)).join("&");
|
|
4148
|
+
const response = await localFetch("/api/review-revisions?" + query);
|
|
4149
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
4150
|
+
const { revisions } = await response.json();
|
|
4151
|
+
const editor = button.closest(".string-map-editor");
|
|
4152
|
+
const items = editor.querySelector(".string-map-items");
|
|
4153
|
+
items.replaceChildren();
|
|
4154
|
+
for (const [key, value] of Object.entries(revisions)) {
|
|
4155
|
+
items.append(editor.querySelector("template").content.cloneNode(true));
|
|
4156
|
+
const row = items.lastElementChild;
|
|
4157
|
+
row.querySelector("[data-map-key]").value = key;
|
|
4158
|
+
row.querySelector("[data-map-value]").value = value;
|
|
4159
|
+
}
|
|
4160
|
+
button.textContent = "Refresh current revisions";
|
|
4161
|
+
dialog.querySelector(".dialog-error").textContent = "";
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
function retentionUseReviewIds(dialog) {
|
|
4165
|
+
if (!dialog.querySelector('[data-field-group="scheduleDocumentId"]')) return [];
|
|
4166
|
+
const informationTypeIds = new Set([...dialog.querySelectorAll('[data-field-group="informationTypeIds"] input:checked')].map(({ value }) => value));
|
|
4167
|
+
const scopeIds = new Set([...dialog.querySelectorAll('[data-field-group="scopeResourceIds"] input:checked')].map(({ value }) => value));
|
|
4168
|
+
const records = state.resources.map(({ record }) => record);
|
|
4169
|
+
const programs = records.filter((record) => record.type === "program" && scopeIds.has(record.id));
|
|
4170
|
+
const useIds = [];
|
|
4171
|
+
for (const program of programs) {
|
|
4172
|
+
const systemIds = new Set(program.systemIds || []);
|
|
4173
|
+
const vendorIds = new Set(program.vendorIds || records
|
|
4174
|
+
.filter((record) => record.type === "vendor" && record.status !== "retired")
|
|
4175
|
+
.map(({ id }) => id));
|
|
4176
|
+
for (const record of records) {
|
|
4177
|
+
if (record.type === "system" && systemIds.has(record.id) && (record.informationTypeIds || []).some((id) => informationTypeIds.has(id))) useIds.push(record.id);
|
|
4178
|
+
if (record.type === "component" && record.status !== "retired" && (record.systemUses || []).some(({ systemId }) => systemIds.has(systemId)) && (record.informationUses || []).some(({ informationTypeId }) => informationTypeIds.has(informationTypeId))) useIds.push(record.id);
|
|
4179
|
+
if (record.type === "vendor" && vendorIds.has(record.id) && (record.informationTypeIds || []).some((id) => informationTypeIds.has(id))) useIds.push(record.id);
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
return useIds;
|
|
4183
|
+
}
|
|
4184
|
+
|
|
3905
4185
|
function readObjectProperty(field) {
|
|
3906
4186
|
if (field.hidden) return undefined;
|
|
3907
4187
|
const kind = field.dataset.objectKind;
|
|
@@ -4074,6 +4354,7 @@ function readGuidedRecord(dialog, base, fields) {
|
|
|
4074
4354
|
.filter((item) => Object.keys(item).length);
|
|
4075
4355
|
}
|
|
4076
4356
|
else if (kind === "structured-object") value = readStructuredObject(group.querySelector(":scope > .structured-object-fields"));
|
|
4357
|
+
else if (kind === "string-map") value = readStringMap(group.querySelector(":scope > .string-map-editor"));
|
|
4077
4358
|
else if (kind === "object") value = raw.trim() ? JSON.parse(raw) : undefined;
|
|
4078
4359
|
else if (kind === "boolean") value = raw === "" ? undefined : raw === "true";
|
|
4079
4360
|
else if (kind === "integer") value = raw === "" ? undefined : Number(raw);
|
|
@@ -4090,8 +4371,16 @@ function readGuidedRecord(dialog, base, fields) {
|
|
|
4090
4371
|
return record;
|
|
4091
4372
|
}
|
|
4092
4373
|
|
|
4093
|
-
function relationCandidates(field) {
|
|
4094
|
-
return state.resources.filter(({ record }) =>
|
|
4374
|
+
function relationCandidates(field, resourceType, fieldName) {
|
|
4375
|
+
return state.resources.filter(({ record }) => {
|
|
4376
|
+
if (!(field.relation.includes("*") || field.relation.includes(record.type))) return false;
|
|
4377
|
+
if (resourceType === "retention-schedule-item" && fieldName === "scheduleDocumentId") {
|
|
4378
|
+
return record.documentKind === "schedule"
|
|
4379
|
+
&& record.workflowScope === "program"
|
|
4380
|
+
&& !["superseded", "retired"].includes(record.status);
|
|
4381
|
+
}
|
|
4382
|
+
return true;
|
|
4383
|
+
});
|
|
4095
4384
|
}
|
|
4096
4385
|
|
|
4097
4386
|
function relationTypeLabel(field, plural = false) {
|
|
@@ -4587,7 +4876,7 @@ function renderNotFound(main) {
|
|
|
4587
4876
|
}
|
|
4588
4877
|
function applyMutationState(result) {
|
|
4589
4878
|
if (result?.state) {
|
|
4590
|
-
state = result.state;
|
|
4879
|
+
state = normalizeAppState(result.state);
|
|
4591
4880
|
} else if (result?.stateRefresh) {
|
|
4592
4881
|
applyFastMutationPatch(result);
|
|
4593
4882
|
scheduleMutationStateRefresh();
|
|
@@ -4598,6 +4887,7 @@ function applyMutationState(result) {
|
|
|
4598
4887
|
}
|
|
4599
4888
|
|
|
4600
4889
|
function applyFastMutationPatch(result) {
|
|
4890
|
+
state.stateToken = null;
|
|
4601
4891
|
if (result.operation === "collection-review" && result.assessment?.resourceType) {
|
|
4602
4892
|
state.collectionReviews[result.assessment.resourceType] = result.assessment;
|
|
4603
4893
|
}
|
|
@@ -4640,10 +4930,12 @@ async function refreshMutationState() {
|
|
|
4640
4930
|
mutationStateRefreshInFlight = true;
|
|
4641
4931
|
let retry = false;
|
|
4642
4932
|
try {
|
|
4643
|
-
const response = await fetch("/api/state");
|
|
4933
|
+
const response = await fetch("/api/state/bootstrap");
|
|
4644
4934
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
4645
|
-
state = await response.json();
|
|
4935
|
+
state = normalizeAppState(await response.json());
|
|
4936
|
+
stateSectionRequests.clear();
|
|
4646
4937
|
render();
|
|
4938
|
+
loadStateForRoute();
|
|
4647
4939
|
scheduleRepositorySyncPoll();
|
|
4648
4940
|
} catch {
|
|
4649
4941
|
retry = true;
|
|
@@ -4956,6 +5248,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
4956
5248
|
@media(max-width:520px){.applicability-dialog form{padding:18px}.applicability-row{grid-template-columns:1fr}.applicability-rows{max-height:42vh}}
|
|
4957
5249
|
@media(max-width:760px){.workflow-findings{grid-template-columns:1fr}}
|
|
4958
5250
|
.choice-tag{overflow-wrap:normal;font-size:10px}.form-field[data-kind="structured-object"],.string-map-property{grid-column:1/-1}.string-map-row .text-button{text-transform:none;letter-spacing:0;color:var(--muted);font-size:11px}
|
|
5251
|
+
.form-field[data-kind="string-map"]{grid-column:1/-1}.string-map-actions{display:flex;flex-wrap:wrap;gap:7px}
|
|
4959
5252
|
@media(max-width:760px){.setup-banner>.button{justify-self:start}.setup-draft-state{justify-content:flex-start;flex-wrap:wrap;gap:12px 22px}.setup-draft-state .button{width:100%}}
|
|
4960
5253
|
@media(max-width:760px){.string-map-row{grid-template-columns:1fr 1fr}.string-map-row .text-button{grid-column:1/-1;justify-self:start}}
|
|
4961
5254
|
|