filegrc 0.9.2 → 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/v7.json +10359 -0
- package/model/v8.json +10947 -0
- package/package.json +1 -1
- package/src/applicability-scope.js +211 -0
- package/src/audit-preparation.js +156 -20
- package/src/batch-review.js +40 -24
- package/src/cli.js +85 -7
- package/src/collection-review.js +16 -3
- package/src/collection-revision.js +23 -3
- package/src/collection-scope.js +94 -7
- package/src/document-activation.js +13 -1
- package/src/git.js +4 -4
- package/src/index.js +3 -0
- package/src/model-migration.js +381 -35
- package/src/obligations.js +142 -39
- package/src/policy-activation.js +5 -0
- 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-lifecycle.js +1 -1
- package/src/program-path.js +13 -8
- package/src/program-readiness.js +43 -13
- package/src/reconciliation.js +15 -3
- package/src/requirement-mapping.js +61 -0
- package/src/retention.js +261 -0
- package/src/server.js +76 -2
- package/src/setup.js +1 -1
- package/src/source-coverage.js +21 -7
- package/src/state.js +171 -2
- package/src/validate.js +106 -11
- package/src/web.js +441 -70
- package/src/workflow.js +33 -13
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,15 +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
|
-
window.addEventListener("hashchange",
|
|
105
|
+
state = normalizeAppState(embedded ? JSON.parse(embedded.textContent) : await fetchJson("/api/state/bootstrap"));
|
|
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
|
-
|
|
112
|
-
|
|
112
|
+
maybeRequestOnboarding();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function handleRouteChange() {
|
|
116
|
+
document.querySelectorAll("dialog.editor[open], dialog.commit-dialog[open], dialog.alert-dialog[open]").forEach((dialog) => dialog.close());
|
|
117
|
+
render();
|
|
118
|
+
loadStateForRoute();
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
function render() {
|
|
@@ -123,7 +129,9 @@ function render() {
|
|
|
123
129
|
const nextNavigation = root.querySelector(".sidebar-nav");
|
|
124
130
|
if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
|
|
125
131
|
const main = root.querySelector("main");
|
|
126
|
-
|
|
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);
|
|
127
135
|
else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
|
|
128
136
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
129
137
|
else if (route.name === "audit-packet") renderAuditPacket(main, route.params);
|
|
@@ -135,6 +143,83 @@ function render() {
|
|
|
135
143
|
bindCommon();
|
|
136
144
|
}
|
|
137
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
|
+
|
|
138
223
|
function parseRoute() {
|
|
139
224
|
const [path, query = ""] = location.hash.replace(/^#\/?/, "").split("?", 2);
|
|
140
225
|
let parts;
|
|
@@ -249,16 +334,33 @@ function topbar(route) {
|
|
|
249
334
|
: route.name === "list" && route.type === "document"
|
|
250
335
|
? documentListTitle(route.params)
|
|
251
336
|
: state.model.resources[route.type]?.pluralTitle || "filegrc";
|
|
252
|
-
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"
|
|
253
344
|
? state.repository.label
|
|
254
345
|
: state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
|
|
255
|
-
const repositoryTone =
|
|
346
|
+
const repositoryTone = repositoryError
|
|
347
|
+
? "warn"
|
|
348
|
+
: repositoryLoading
|
|
349
|
+
? "neutral"
|
|
350
|
+
: state.repository?.mode === "trunk"
|
|
256
351
|
? repositoryStatusTone(state.repository.status)
|
|
257
352
|
: state.git.clean ? "good" : "warn";
|
|
258
|
-
|
|
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>';
|
|
259
357
|
}
|
|
260
358
|
|
|
261
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
|
+
}
|
|
262
364
|
const progress = dashboardProgramReadiness(state.programReadiness);
|
|
263
365
|
const detail = progress.complete + " of " + progress.total + " readiness items complete";
|
|
264
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>';
|
|
@@ -287,20 +389,25 @@ function renderHome(main) {
|
|
|
287
389
|
const setupPending = !setupSystem
|
|
288
390
|
|| setupSystem.status !== "active"
|
|
289
391
|
|| activeProgram().assuranceGoal === "none";
|
|
290
|
-
const acceptedEventTriggers = state.obligations.triggers
|
|
291
|
-
const openObligations = state.obligations.items
|
|
392
|
+
const acceptedEventTriggers = activePolicyEventTriggers(state.obligations.triggers);
|
|
393
|
+
const openObligations = activeOperationItems(state.obligations.items);
|
|
292
394
|
const previewObligations = distinctObligationPreviews(openObligations, 3);
|
|
293
|
-
const obligationHeading = openObligations.some((item) => item.status !== "proposed") ? "Due Windows" : "Starter Proposals";
|
|
294
395
|
const setupBanner = setupPending ? initialSetupBanner() : "";
|
|
295
396
|
const auditPanel = program.evidenceReady
|
|
296
397
|
? '<section class="panel audit-panel"><div class="panel-head"><div><p class="kicker">Optional next phase</p><h3>' + esc(activeFirm ? titleCase(activeAudit.record.title) : "Target: " + program.target.label) + '</h3></div>' + (activeAudit ? '<a href="#/resource/audit/' + encodeURIComponent(activeAudit.record.id) + '">Open audit</a>' : '<a href="#/resources/audit">Engagements</a>') + '</div>' +
|
|
297
398
|
(activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '</section>'
|
|
298
399
|
: "";
|
|
400
|
+
const obligationPanel = program.evidenceReady || openObligations.length
|
|
401
|
+
? '<section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>Due Windows</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(previewObligations) + '</section>'
|
|
402
|
+
: "";
|
|
403
|
+
const eventPanel = acceptedEventTriggers.length
|
|
404
|
+
? '<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">Trigger work</a></div>' + eventReminderPreview(orderedPolicyEventTriggers(acceptedEventTriggers).slice(0, 4)) + '</section>'
|
|
405
|
+
: "";
|
|
406
|
+
const operationPanels = obligationPanel + eventPanel;
|
|
407
|
+
const overviewPanels = operationPanels + auditPanel;
|
|
299
408
|
main.innerHTML = '<div class="page home-page"><section class="hero overview-hero"><div><p class="kicker">Current program state</p><h2>' + esc(titleCase(state.workspace.title)) + '</h2><p>' + esc(state.workspace.description || "Governance, risk, controls, evidence, and audit work maintained as plain files in Git.") + '</p></div></section>' + setupBanner + readinessOverview() +
|
|
300
|
-
'<div class="overview-grid"
|
|
301
|
-
|
|
302
|
-
auditPanel + '</div></div>';
|
|
303
|
-
main.querySelector("#resume-setup")?.addEventListener("click", () => requestOnboarding({ setupOnly: Boolean(initialSetupSystem()) }));
|
|
409
|
+
(overviewPanels ? '<div class="overview-grid">' + overviewPanels + '</div>' : '') + '</div>';
|
|
410
|
+
main.querySelector("#resume-setup")?.addEventListener("click", () => requestOnboarding({ setupOnly: true }));
|
|
304
411
|
}
|
|
305
412
|
|
|
306
413
|
function initialSetupSystem() {
|
|
@@ -359,7 +466,7 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) {
|
|
|
359
466
|
const progress = stageProgress(stage);
|
|
360
467
|
main.innerHTML = '<div class="page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
361
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>' +
|
|
362
|
-
(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>';
|
|
363
470
|
main.querySelector("[data-show-evidence-families]")?.addEventListener("click", (event) => {
|
|
364
471
|
main.querySelectorAll("[data-evidence-family-extra]").forEach((card) => { card.hidden = false; });
|
|
365
472
|
event.currentTarget.remove();
|
|
@@ -767,7 +874,8 @@ function recordWorkflowItems(type, id) {
|
|
|
767
874
|
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
768
875
|
return [
|
|
769
876
|
...(state.workflow?.findings || []),
|
|
770
|
-
...(state.workflow?.workItems || [])
|
|
877
|
+
...(state.workflow?.workItems || []),
|
|
878
|
+
...programReadinessWorkflowItems()
|
|
771
879
|
].filter((item) => (
|
|
772
880
|
activeStates.has(item.state)
|
|
773
881
|
&& (
|
|
@@ -782,6 +890,9 @@ function recordWorkflowItems(type, id) {
|
|
|
782
890
|
}
|
|
783
891
|
|
|
784
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
|
+
}
|
|
785
896
|
const items = recordWorkflowItems(type, entry.record.id);
|
|
786
897
|
if (!items.length) return '<span class="record-workflow-clear">No calculated action</span>';
|
|
787
898
|
const item = items[0];
|
|
@@ -876,6 +987,14 @@ function workflowItemHref(item) {
|
|
|
876
987
|
...(item.actions || []).map((action) => action.command),
|
|
877
988
|
item.nextAction?.command
|
|
878
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
|
+
}
|
|
879
998
|
const applicabilityCommand = commands.find((command) => command.includes(" review-applicability "));
|
|
880
999
|
const applicabilityType = applicabilityCommand?.match(/--type\s+([a-z0-9-]+)/)?.[1];
|
|
881
1000
|
if (
|
|
@@ -886,6 +1005,9 @@ function workflowItemHref(item) {
|
|
|
886
1005
|
) {
|
|
887
1006
|
return "#/resource/" + encodeURIComponent(applicabilityType) + "/" + encodeURIComponent(item.subject.id);
|
|
888
1007
|
}
|
|
1008
|
+
if (commands.some((command) => command.includes(" scaffold retention-schedule-item"))) {
|
|
1009
|
+
return retentionScheduleItemHref(item);
|
|
1010
|
+
}
|
|
889
1011
|
if (applicabilityType && state.model.resources[applicabilityType]) {
|
|
890
1012
|
return "#/resources/" + encodeURIComponent(applicabilityType) + "?review=1";
|
|
891
1013
|
}
|
|
@@ -936,6 +1058,21 @@ function workflowItemHref(item) {
|
|
|
936
1058
|
return stage ? "#/stage/" + stage : "";
|
|
937
1059
|
}
|
|
938
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
|
+
|
|
939
1076
|
function workflowItemDetail(item) {
|
|
940
1077
|
if (item.dueOn) return "Due " + item.dueOn;
|
|
941
1078
|
if (item.availableOn) return "Available " + item.availableOn;
|
|
@@ -1010,6 +1147,59 @@ function renderEvidenceReadiness() {
|
|
|
1010
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>';
|
|
1011
1148
|
}
|
|
1012
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
|
+
|
|
1013
1203
|
function evidenceSourceCheckLabel(name) {
|
|
1014
1204
|
return ({
|
|
1015
1205
|
active: "activate source",
|
|
@@ -1112,11 +1302,12 @@ function stagePageItems(stage, destination) {
|
|
|
1112
1302
|
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready"]);
|
|
1113
1303
|
const items = [
|
|
1114
1304
|
...(state.workflow?.findings || []),
|
|
1115
|
-
...(state.workflow?.workItems || [])
|
|
1305
|
+
...(state.workflow?.workItems || []),
|
|
1306
|
+
...programReadinessWorkflowItems()
|
|
1116
1307
|
].filter((item) => (
|
|
1117
1308
|
activeStates.has(item.state)
|
|
1118
1309
|
&& (
|
|
1119
|
-
item.stage === stage.id
|
|
1310
|
+
workflowUiStage(item.stage) === stage.id
|
|
1120
1311
|
|| stage.id === "scope"
|
|
1121
1312
|
&& destination.type === "appointment"
|
|
1122
1313
|
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
@@ -1130,6 +1321,7 @@ function stagePageItems(stage, destination) {
|
|
|
1130
1321
|
));
|
|
1131
1322
|
}
|
|
1132
1323
|
return items.filter((item) => {
|
|
1324
|
+
if (destination.type === "program" && item.key === "program.scope.criteria") return false;
|
|
1133
1325
|
if (
|
|
1134
1326
|
destination.type === "requirement"
|
|
1135
1327
|
&& item.subject?.type === "requirement"
|
|
@@ -1162,6 +1354,10 @@ function stagePageItems(stage, destination) {
|
|
|
1162
1354
|
));
|
|
1163
1355
|
}
|
|
1164
1356
|
|
|
1357
|
+
function workflowUiStage(stageId) {
|
|
1358
|
+
return stageId === "operation" ? "run" : stageId;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1165
1361
|
function operationProgress() {
|
|
1166
1362
|
const program = state.programReadiness;
|
|
1167
1363
|
const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
|
|
@@ -1173,7 +1369,7 @@ function operationProgress() {
|
|
|
1173
1369
|
: Boolean(program?.evidenceReady);
|
|
1174
1370
|
const overdue = state.obligations.counts.overdue || 0;
|
|
1175
1371
|
const blocked = state.obligations.counts.blocked || 0;
|
|
1176
|
-
const complete = Boolean(program?.
|
|
1372
|
+
const complete = Boolean(program?.operating);
|
|
1177
1373
|
if (complete) {
|
|
1178
1374
|
return {
|
|
1179
1375
|
percent: 100,
|
|
@@ -1214,6 +1410,19 @@ function operationProgress() {
|
|
|
1214
1410
|
detail: "Record the management candidate period start when evidence collection begins."
|
|
1215
1411
|
};
|
|
1216
1412
|
}
|
|
1413
|
+
if (program?.evidenceReady && candidateStarted) {
|
|
1414
|
+
const actions = program?.stages?.find((stage) => stage.id === "operation")?.counts?.action || 0;
|
|
1415
|
+
return {
|
|
1416
|
+
percent: 0,
|
|
1417
|
+
complete: 0,
|
|
1418
|
+
total: 1,
|
|
1419
|
+
status: "Needs work",
|
|
1420
|
+
tone: "warn",
|
|
1421
|
+
detail: actions
|
|
1422
|
+
? actions + " Step 4 " + pluralize("item", actions) + (actions === 1 ? " needs" : " need") + " work."
|
|
1423
|
+
: "Complete the current Step 4 readiness work."
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1217
1426
|
return {
|
|
1218
1427
|
percent: 0,
|
|
1219
1428
|
complete: 0,
|
|
@@ -1278,42 +1487,67 @@ function renderExternalEvidenceSection() {
|
|
|
1278
1487
|
function renderObligations(main, params = new URLSearchParams()) {
|
|
1279
1488
|
const stage = READINESS_STAGES.find((candidate) => candidate.id === "run");
|
|
1280
1489
|
const plan = state.obligations;
|
|
1490
|
+
const operationLocked = !state.programReadiness?.evidenceReady;
|
|
1281
1491
|
const visibleCardLimit = 6;
|
|
1282
|
-
const
|
|
1283
|
-
const
|
|
1284
|
-
const cards =
|
|
1285
|
-
const more =
|
|
1286
|
-
? '<button class="button obligation-more" type="button" data-expand-obligations="' + status + '" data-total="' +
|
|
1492
|
+
const renderBoard = (items, statuses) => statuses.map((status) => {
|
|
1493
|
+
const statusItems = obligationBoardItems(items, status);
|
|
1494
|
+
const cards = statusItems.map((item, index) => obligationCard(item, index >= visibleCardLimit)).join("");
|
|
1495
|
+
const more = statusItems.length > visibleCardLimit
|
|
1496
|
+
? '<button class="button obligation-more" type="button" data-expand-obligations="' + status + '" data-total="' + statusItems.length + '" aria-expanded="false">Show ' + (statusItems.length - visibleCardLimit) + ' more</button>'
|
|
1287
1497
|
: "";
|
|
1288
|
-
return '<section class="obligation-column" data-obligation-column="' + status + '"><div class="obligation-column-head"><span class="badge status-' + status + '">' + esc(properCase(status)) + '</span><strong>' +
|
|
1498
|
+
return '<section class="obligation-column" data-obligation-column="' + status + '"><div class="obligation-column-head"><span class="badge status-' + status + '">' + esc(properCase(status)) + '</span><strong>' + statusItems.length + '</strong></div><div class="obligation-cards">' + (statusItems.length ? cards : empty("Nothing " + status + ".")) + '</div>' + more + '</section>';
|
|
1289
1499
|
}).join("");
|
|
1290
1500
|
const eventTriggerLimit = 6;
|
|
1291
1501
|
const orderedTriggers = orderedPolicyEventTriggers(plan.triggers);
|
|
1292
|
-
const
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1295
|
-
|
|
1502
|
+
const acceptedTriggers = activePolicyEventTriggers(orderedTriggers);
|
|
1503
|
+
const proposedTriggers = orderedTriggers.filter(({ programStatus }) => programStatus === "proposed");
|
|
1504
|
+
const currentItems = activeOperationItems(plan.items);
|
|
1505
|
+
const proposedItems = plan.items.filter(({ status }) => status === "proposed");
|
|
1506
|
+
const renderEvents = (items, scope, description) => {
|
|
1507
|
+
const triggers = items.map((trigger, index) => policyEventTrigger(trigger, index, index >= eventTriggerLimit, scope)).join("");
|
|
1508
|
+
const eventMore = items.length > eventTriggerLimit
|
|
1509
|
+
? '<button class="button policy-event-more" type="button" data-expand-policy-events="' + scope + '" data-total="' + items.length + '" aria-expanded="false">Show ' + (items.length - eventTriggerLimit) + ' more events</button>'
|
|
1510
|
+
: "";
|
|
1511
|
+
return '<section class="workflow-section event-reminders" data-policy-event-section="' + scope + '"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>' + description + '</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div>' + eventMore + '</section>';
|
|
1512
|
+
};
|
|
1513
|
+
const renderQueue = (items, statuses, description) => '<section class="workflow-section work-queue-section"><div class="section-head"><div><p class="kicker">Recurring, event, and assigned work</p><h2>Work Queue</h2><p>' + description + '</p></div><div class="page-actions">' + (!state.readOnly ? '<button class="button" type="button" data-new-action-item>New task</button>' : "") + '<a class="button" href="#/resources/obligation">Edit schedules</a></div></div><div class="obligation-board">' + renderBoard(items, statuses) + '</div></section>';
|
|
1296
1514
|
const feedback = policyEventFeedback
|
|
1297
1515
|
? '<section class="policy-event-feedback" role="status" aria-live="polite"><span class="status-dot good"></span><div><strong>Work added to the Work Queue</strong><p>' + esc(policyEventFeedback.name + " created " + policyEventFeedback.taskCount + " " + pluralize("task", policyEventFeedback.taskCount) + ".") + '</p></div><button class="button" type="button" data-view-added-work>View Work Queue</button><button class="icon-button" type="button" data-dismiss-policy-event-feedback aria-label="Dismiss confirmation">×</button></section>'
|
|
1298
1516
|
: "";
|
|
1517
|
+
const operationGate = operationLocked
|
|
1518
|
+
? '<section class="workflow-section operation-gate"><div><p class="kicker">Finish Step 3 first</p><h2>Get the program Evidence Ready</h2><p>Confirm the remaining Controls, schedules, evidence sources, and governed content. Any adopted work and Policy Events that can happen now stay available below.</p></div><a class="button primary" href="#/stage/controls">Continue Evidence Ready work</a></section>'
|
|
1519
|
+
: "";
|
|
1520
|
+
const activeOperation = operationLocked
|
|
1521
|
+
? (acceptedTriggers.length ? renderEvents(acceptedTriggers, "active", "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.") : "") +
|
|
1522
|
+
(currentItems.length ? renderQueue(currentItems, ["upcoming", "blocked", "due", "overdue"], "Complete work the program has already adopted. Each card shows its due window, source, and next action.") : "")
|
|
1523
|
+
: "";
|
|
1524
|
+
const setupTriggers = operationLocked ? proposedTriggers : orderedTriggers;
|
|
1525
|
+
const setupItems = operationLocked ? proposedItems : plan.items;
|
|
1526
|
+
const setupStatuses = operationLocked ? ["proposed"] : ["proposed", "upcoming", "blocked", "due", "overdue"];
|
|
1527
|
+
const operationSetupOpen = operationLocked ? "" : " open";
|
|
1528
|
+
const operationSetupSummary = operationLocked
|
|
1529
|
+
? '<summary>Preview ' + proposedTriggers.length + ' proposed Policy Events and ' + proposedItems.length + ' proposed Work Queue items</summary>'
|
|
1530
|
+
: "";
|
|
1299
1531
|
main.innerHTML = '<div class="page obligation-board-page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
|
|
1300
1532
|
'<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(stageProgress(stage)) + '</section>' +
|
|
1301
1533
|
feedback +
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
'<
|
|
1305
|
-
|
|
1534
|
+
operationGate +
|
|
1535
|
+
activeOperation +
|
|
1536
|
+
'<details class="operation-setup-preview"' + operationSetupOpen + '>' + operationSetupSummary +
|
|
1537
|
+
renderEvents(setupTriggers, "setup", operationLocked ? "Review these starter workflows. They become available when their governing work is adopted." : "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.") +
|
|
1538
|
+
renderQueue(setupItems, setupStatuses, operationLocked ? "Review proposed schedules before adopting them." : "Complete scheduled work and assigned follow-up here. Each card shows its due window, source, and next action.") +
|
|
1539
|
+
renderExternalEvidenceSection() + '</details></div>';
|
|
1306
1540
|
main.querySelectorAll("[data-start-event]").forEach((button) => button.addEventListener("click", () => {
|
|
1307
1541
|
const trigger = plan.triggers.find((item) => item.eventType === button.dataset.startEvent);
|
|
1308
1542
|
if (trigger) openObligationEventDialog(trigger);
|
|
1309
1543
|
}));
|
|
1310
|
-
main.
|
|
1544
|
+
main.querySelectorAll("[data-expand-policy-events]").forEach((button) => button.addEventListener("click", (event) => {
|
|
1311
1545
|
const button = event.currentTarget;
|
|
1312
1546
|
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
1313
|
-
|
|
1547
|
+
button.closest("[data-policy-event-section]").querySelectorAll(".policy-event-row[data-collapsed]").forEach((row) => { row.hidden = expanded; });
|
|
1314
1548
|
button.setAttribute("aria-expanded", String(!expanded));
|
|
1315
|
-
button.textContent = expanded ? "Show " + (
|
|
1316
|
-
});
|
|
1549
|
+
button.textContent = expanded ? "Show " + (Number(button.dataset.total) - eventTriggerLimit) + " more events" : "Show fewer events";
|
|
1550
|
+
}));
|
|
1317
1551
|
main.querySelector("[data-view-added-work]")?.addEventListener("click", () => main.querySelector(".work-queue-section")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
|
1318
1552
|
main.querySelector("[data-dismiss-policy-event-feedback]")?.addEventListener("click", (event) => {
|
|
1319
1553
|
policyEventFeedback = null;
|
|
@@ -1336,9 +1570,9 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
1336
1570
|
const item = plan.items.find((candidate) => candidate.key === button.dataset.completeAction);
|
|
1337
1571
|
if (item) openActionCompletion(item);
|
|
1338
1572
|
}));
|
|
1339
|
-
main.
|
|
1573
|
+
main.querySelectorAll("[data-new-action-item]").forEach((button) => button.addEventListener("click", () => openEditor("action-item", null, {
|
|
1340
1574
|
description: "Create a task only when follow-up from another record needs its own assignee, deadline, and completion proof. Point it to that source record; it will remain in Work Queue until done or canceled."
|
|
1341
|
-
}));
|
|
1575
|
+
})));
|
|
1342
1576
|
const requestedEvent = params.get("event");
|
|
1343
1577
|
if (requestedEvent || params.get("section") === "events") {
|
|
1344
1578
|
queueMicrotask(() => {
|
|
@@ -1391,8 +1625,16 @@ function orderedPolicyEventTriggers(triggers) {
|
|
|
1391
1625
|
));
|
|
1392
1626
|
}
|
|
1393
1627
|
|
|
1394
|
-
function
|
|
1395
|
-
|
|
1628
|
+
function activePolicyEventTriggers(triggers) {
|
|
1629
|
+
return triggers.filter(({ programStatus }) => programStatus === "accepted");
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function activeOperationItems(items) {
|
|
1633
|
+
return items.filter(({ status }) => ["upcoming", "blocked", "due", "overdue"].includes(status));
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function policyEventTrigger(trigger, index, collapsed = false, scope = "events") {
|
|
1637
|
+
const tooltipId = "policy-event-tooltip-" + scope + "-" + index;
|
|
1396
1638
|
const proposed = trigger.programStatus === "proposed";
|
|
1397
1639
|
const unavailable = state.readOnly || proposed;
|
|
1398
1640
|
const availability = proposed
|
|
@@ -1473,7 +1715,7 @@ function actionCompletionPlan(item) {
|
|
|
1473
1715
|
href: "#/resource/action-item/" + encodeURIComponent(action.record.id)
|
|
1474
1716
|
};
|
|
1475
1717
|
}
|
|
1476
|
-
const type = state.model.obligationActivities?.[obligation.record.activityType]?.completionType;
|
|
1718
|
+
const type = item.completionType || state.model.obligationActivities?.[obligation.record.activityType]?.completionType;
|
|
1477
1719
|
if (!type) {
|
|
1478
1720
|
return {
|
|
1479
1721
|
blocked: "Review completion type",
|
|
@@ -1484,7 +1726,7 @@ function actionCompletionPlan(item) {
|
|
|
1484
1726
|
}
|
|
1485
1727
|
|
|
1486
1728
|
function obligationCompletionPlan(item) {
|
|
1487
|
-
const type = state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
|
|
1729
|
+
const type = item.completionType || state.model.obligationActivities?.[item.activityType]?.completionType || "evidence";
|
|
1488
1730
|
if (!currentPeopleForParties(item.ownerIds || []).length) {
|
|
1489
1731
|
return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
|
|
1490
1732
|
}
|
|
@@ -1552,10 +1794,13 @@ function obligationCompletionSeed(type, item, obligation) {
|
|
|
1552
1794
|
return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: reviewerPeople, completedOn: date, outcome: "passed", changesRequired: false, evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
1553
1795
|
}
|
|
1554
1796
|
if (type === "risk-assessment") {
|
|
1555
|
-
return { ...common, status: "complete", completedOn: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds: reviewerPeople, methodology:
|
|
1797
|
+
return { ...common, status: "complete", completedOn: date, assessmentKind: "enterprise-risk", scope: "In-scope SOC 2 systems and dependencies", assessorIds: responsiblePeople, reviewerIds: reviewerPeople, methodology: activeProgram().riskMethodology?.method || "Documented risk methodology", summary: "Assessment completed; link the supporting evidence and resulting risks.", evidenceIds: [], approvedOn: date };
|
|
1556
1798
|
}
|
|
1557
1799
|
if (type === "attestation") {
|
|
1558
|
-
|
|
1800
|
+
const personId = [...(item.subjectResourceIds || []), ...(obligation.scopeResourceIds || [])].find((id) => state.resources.some(({ record }) => record.id === id && record.type === "person"));
|
|
1801
|
+
const primarySubjectIds = [...new Set([obligation.templateResourceId, ...(obligation.scopeResourceIds || [])].filter((id) => state.resources.some(({ record }) => record.id === id && ["policy", "document", "training", "action-item"].includes(record.type))))];
|
|
1802
|
+
const subjectResourceIds = primarySubjectIds.length ? primarySubjectIds : [...(obligation.policyIds || [])];
|
|
1803
|
+
return { ...common, status: "completed", subjectResourceIds, personId, attestationKind: item.activityType || "completion", assignedOn: item.dueWindowStart, dueOn: item.dueWindowEnd, completedOn: date, attestationMethod: "git-approval" };
|
|
1559
1804
|
}
|
|
1560
1805
|
if (type === "access-review") {
|
|
1561
1806
|
return { ...common, status: "complete", completedOn: date, reviewerIds: responsiblePeople, systemIds: inScopeSystems, scope: "Privileged, production, and important-system access", outcome: "passed", evidenceIds: [], approvedByIds: reviewerPeople, approvedOn: date, coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
@@ -1570,15 +1815,23 @@ function obligationCompletionSeed(type, item, obligation) {
|
|
|
1570
1815
|
return { ...common, status: "complete", controlId: obligation.controlIds?.[0] || "", testKinds: [item.activityType || "control-operation"], performedBy: "management", testerIds: responsiblePeople, reviewerIds: reviewerPeople, completedOn: date, reviewedOn: date, outcome: "passed", evidenceIds: [], coverage: rangeCoverage(item.dueWindowStart, item.dueWindowEnd) };
|
|
1571
1816
|
}
|
|
1572
1817
|
if (type === "control-activity") {
|
|
1818
|
+
const allowedScopeTypes = new Set(state.model.relationGroups?.["obligation-scope"] || []);
|
|
1819
|
+
const requestedScopeIds = item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || [];
|
|
1820
|
+
const validScopeIds = requestedScopeIds.filter((id) => state.resources.some(({ record }) => (
|
|
1821
|
+
record.id === id && allowedScopeTypes.has(record.type)
|
|
1822
|
+
)));
|
|
1823
|
+
const fallbackScopeIds = inScopeSystems.length
|
|
1824
|
+
? inScopeSystems
|
|
1825
|
+
: (item.controlIds || obligation.controlIds || []).length
|
|
1826
|
+
? (item.controlIds || obligation.controlIds)
|
|
1827
|
+
: [state.workspace.id];
|
|
1573
1828
|
return {
|
|
1574
1829
|
...common,
|
|
1575
1830
|
status: "complete",
|
|
1576
1831
|
profileId: item.completionProfile || item.activityType,
|
|
1577
1832
|
obligationId: item.obligationId,
|
|
1578
1833
|
controlIds: item.controlIds || obligation.controlIds || [],
|
|
1579
|
-
scopeResourceIds:
|
|
1580
|
-
? (item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds)
|
|
1581
|
-
: [state.workspace.id],
|
|
1834
|
+
scopeResourceIds: validScopeIds.length ? validScopeIds : fallbackScopeIds,
|
|
1582
1835
|
performerIds: responsiblePeople,
|
|
1583
1836
|
completedAt: timestamp,
|
|
1584
1837
|
method: "",
|
|
@@ -2223,9 +2476,26 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
2223
2476
|
renderRows();
|
|
2224
2477
|
syncRoute();
|
|
2225
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
|
+
});
|
|
2226
2482
|
main.querySelector("#review-applicability")?.addEventListener("click", () => openApplicabilityReviewDialog(type, entries));
|
|
2227
2483
|
main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog(type));
|
|
2228
|
-
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
|
+
}
|
|
2229
2499
|
if (params.get("review") === "1" && !state.readOnly) {
|
|
2230
2500
|
queueMicrotask(() => main.querySelector("#review-applicability")?.click());
|
|
2231
2501
|
}
|
|
@@ -2888,6 +3158,12 @@ function rendererSettingsEntry() {
|
|
|
2888
3158
|
return state.resources.find(({ record }) => record.type === "renderer-settings");
|
|
2889
3159
|
}
|
|
2890
3160
|
|
|
3161
|
+
function maybeRequestOnboarding() {
|
|
3162
|
+
if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true && !initialSetupSystem()) {
|
|
3163
|
+
queueMicrotask(requestOnboarding);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
|
|
2891
3167
|
function requestOnboarding({ setupOnly = false } = {}) {
|
|
2892
3168
|
if (state.readOnly || onboardingDialog || !rendererSettingsEntry()) return;
|
|
2893
3169
|
if (parseRoute().name !== "home") {
|
|
@@ -3089,7 +3365,7 @@ async function saveOnboarding(draft = false) {
|
|
|
3089
3365
|
try {
|
|
3090
3366
|
const response = await localFetch("/api/setup", {
|
|
3091
3367
|
method: "POST",
|
|
3092
|
-
headers: { "content-type": "application/json" },
|
|
3368
|
+
headers: { "content-type": "application/json", prefer: "respond-async" },
|
|
3093
3369
|
body: JSON.stringify({
|
|
3094
3370
|
serviceName: onboardingDraft.serviceName,
|
|
3095
3371
|
boundary: onboardingDraft.scope,
|
|
@@ -3335,6 +3611,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
3335
3611
|
...required,
|
|
3336
3612
|
...(definition.listFields || []),
|
|
3337
3613
|
...(definition.formFields || []),
|
|
3614
|
+
...Object.entries(fields).filter(([, field]) => field.relation || field.relationGroup).map(([name]) => name),
|
|
3338
3615
|
...Object.entries(fields).filter(([, field]) => field.requiredWhen).map(([name]) => name),
|
|
3339
3616
|
...oneOf
|
|
3340
3617
|
])].filter((name) => !["id", "type"].includes(name) && fields[name]);
|
|
@@ -3602,27 +3879,29 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
3602
3879
|
const requiredMark = required || field.requiredWhen || oneOfRequired
|
|
3603
3880
|
? '<span class="required-mark" ' + (required || oneOfActive ? "" : "hidden") + '>' + (oneOfRequired ? "One Required" : "Required") + '</span>'
|
|
3604
3881
|
: "";
|
|
3882
|
+
const relation = field.relation || state.model.relationGroups?.[field.relationGroup];
|
|
3883
|
+
const relationField = relation ? { ...field, relation } : field;
|
|
3605
3884
|
const help = name === "title"
|
|
3606
3885
|
? (editing ? "Renaming this record will not change its stable ID." : "A stable ID and file name will be generated from this value.")
|
|
3607
|
-
:
|
|
3886
|
+
: relation ? relationHelp(relationField)
|
|
3608
3887
|
: "";
|
|
3609
3888
|
let control;
|
|
3610
3889
|
if (field.managed) {
|
|
3611
3890
|
control = '<textarea readonly spellcheck="false" placeholder="Filled when approval is saved">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
|
|
3612
3891
|
return fieldWrap(name, "object", label, requiredMark, control, "Managed by filegrc from the exact companion Markdown revisions", false);
|
|
3613
3892
|
}
|
|
3614
|
-
if (
|
|
3615
|
-
const candidates = relationCandidates(
|
|
3893
|
+
if (relation && field.type === "array") {
|
|
3894
|
+
const candidates = relationCandidates(relationField, type, name);
|
|
3616
3895
|
control = candidates.length
|
|
3617
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>'
|
|
3618
|
-
: 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>';
|
|
3619
3898
|
return fieldWrap(name, "relation-array", label, requiredMark, control, help, required);
|
|
3620
3899
|
}
|
|
3621
|
-
if (
|
|
3622
|
-
const candidates = relationCandidates(
|
|
3900
|
+
if (relation) {
|
|
3901
|
+
const candidates = relationCandidates(relationField, type, name);
|
|
3623
3902
|
control = candidates.length
|
|
3624
|
-
? '<select><option value="">Select ' + esc(relationTypeLabel(
|
|
3625
|
-
: 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>';
|
|
3626
3905
|
return fieldWrap(name, "relation", label, requiredMark, control, help, required);
|
|
3627
3906
|
}
|
|
3628
3907
|
if (name === "classificationId") {
|
|
@@ -3658,6 +3937,13 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
|
|
|
3658
3937
|
control = '<div class="structured-object-fields" data-object-type="' + esc(field.objectType) + '">' + objectPropertyFields(schema, value || {}) + '</div>';
|
|
3659
3938
|
return fieldWrap(name, "structured-object", label, requiredMark, control, "Fill only the details that apply.", required);
|
|
3660
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
|
+
}
|
|
3661
3947
|
control = '<textarea spellcheck="false" placeholder="{ }">' + esc(value === undefined ? "" : JSON.stringify(value, null, 2)) + '</textarea>';
|
|
3662
3948
|
return fieldWrap(name, "object", label, requiredMark, control, "JSON object", required);
|
|
3663
3949
|
}
|
|
@@ -3752,10 +4038,11 @@ function objectArrayItem(schema, value = {}) {
|
|
|
3752
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>';
|
|
3753
4039
|
}
|
|
3754
4040
|
|
|
3755
|
-
function stringMapEditor(value = {}, name = "item") {
|
|
4041
|
+
function stringMapEditor(value = {}, name = "item", bindCurrent = false) {
|
|
3756
4042
|
const entries = Object.entries(value);
|
|
3757
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>';
|
|
3758
|
-
|
|
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>';
|
|
3759
4046
|
}
|
|
3760
4047
|
|
|
3761
4048
|
function wireStructuredObjectEditors(dialog) {
|
|
@@ -3772,6 +4059,17 @@ function wireStructuredObjectEditors(dialog) {
|
|
|
3772
4059
|
});
|
|
3773
4060
|
});
|
|
3774
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
|
+
}
|
|
3775
4073
|
const add = event.target.closest("[data-add-string-map]");
|
|
3776
4074
|
if (add) {
|
|
3777
4075
|
const editor = add.closest(".string-map-editor");
|
|
@@ -3830,6 +4128,60 @@ function wireStructuredObjectEditors(dialog) {
|
|
|
3830
4128
|
});
|
|
3831
4129
|
}
|
|
3832
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
|
+
|
|
3833
4185
|
function readObjectProperty(field) {
|
|
3834
4186
|
if (field.hidden) return undefined;
|
|
3835
4187
|
const kind = field.dataset.objectKind;
|
|
@@ -4002,6 +4354,7 @@ function readGuidedRecord(dialog, base, fields) {
|
|
|
4002
4354
|
.filter((item) => Object.keys(item).length);
|
|
4003
4355
|
}
|
|
4004
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"));
|
|
4005
4358
|
else if (kind === "object") value = raw.trim() ? JSON.parse(raw) : undefined;
|
|
4006
4359
|
else if (kind === "boolean") value = raw === "" ? undefined : raw === "true";
|
|
4007
4360
|
else if (kind === "integer") value = raw === "" ? undefined : Number(raw);
|
|
@@ -4018,8 +4371,16 @@ function readGuidedRecord(dialog, base, fields) {
|
|
|
4018
4371
|
return record;
|
|
4019
4372
|
}
|
|
4020
4373
|
|
|
4021
|
-
function relationCandidates(field) {
|
|
4022
|
-
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
|
+
});
|
|
4023
4384
|
}
|
|
4024
4385
|
|
|
4025
4386
|
function relationTypeLabel(field, plural = false) {
|
|
@@ -4515,7 +4876,7 @@ function renderNotFound(main) {
|
|
|
4515
4876
|
}
|
|
4516
4877
|
function applyMutationState(result) {
|
|
4517
4878
|
if (result?.state) {
|
|
4518
|
-
state = result.state;
|
|
4879
|
+
state = normalizeAppState(result.state);
|
|
4519
4880
|
} else if (result?.stateRefresh) {
|
|
4520
4881
|
applyFastMutationPatch(result);
|
|
4521
4882
|
scheduleMutationStateRefresh();
|
|
@@ -4526,6 +4887,7 @@ function applyMutationState(result) {
|
|
|
4526
4887
|
}
|
|
4527
4888
|
|
|
4528
4889
|
function applyFastMutationPatch(result) {
|
|
4890
|
+
state.stateToken = null;
|
|
4529
4891
|
if (result.operation === "collection-review" && result.assessment?.resourceType) {
|
|
4530
4892
|
state.collectionReviews[result.assessment.resourceType] = result.assessment;
|
|
4531
4893
|
}
|
|
@@ -4538,6 +4900,12 @@ function applyFastMutationPatch(result) {
|
|
|
4538
4900
|
state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
|
|
4539
4901
|
}
|
|
4540
4902
|
}
|
|
4903
|
+
for (const record of [result.workspace, result.program, result.system, result.renderer, result.commitment].filter(Boolean)) {
|
|
4904
|
+
const entry = state.resources.find(({ record: current }) => current.id === record.id);
|
|
4905
|
+
if (entry) entry.record = record;
|
|
4906
|
+
else state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
|
|
4907
|
+
if (record.type === "workspace") state.workspace = record;
|
|
4908
|
+
}
|
|
4541
4909
|
if (result.synchronization) {
|
|
4542
4910
|
state.repository = {
|
|
4543
4911
|
...state.repository,
|
|
@@ -4548,7 +4916,7 @@ function applyFastMutationPatch(result) {
|
|
|
4548
4916
|
backgroundSynchronization: result.synchronization.status === "syncing" ? result.synchronization : null
|
|
4549
4917
|
};
|
|
4550
4918
|
}
|
|
4551
|
-
state.readOnly = true;
|
|
4919
|
+
if (result.synchronization?.status === "syncing") state.readOnly = true;
|
|
4552
4920
|
}
|
|
4553
4921
|
|
|
4554
4922
|
function scheduleMutationStateRefresh(delay = 0) {
|
|
@@ -4562,10 +4930,12 @@ async function refreshMutationState() {
|
|
|
4562
4930
|
mutationStateRefreshInFlight = true;
|
|
4563
4931
|
let retry = false;
|
|
4564
4932
|
try {
|
|
4565
|
-
const response = await fetch("/api/state");
|
|
4933
|
+
const response = await fetch("/api/state/bootstrap");
|
|
4566
4934
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
4567
|
-
state = await response.json();
|
|
4935
|
+
state = normalizeAppState(await response.json());
|
|
4936
|
+
stateSectionRequests.clear();
|
|
4568
4937
|
render();
|
|
4938
|
+
loadStateForRoute();
|
|
4569
4939
|
scheduleRepositorySyncPoll();
|
|
4570
4940
|
} catch {
|
|
4571
4941
|
retry = true;
|
|
@@ -4652,7 +5022,7 @@ function setMutationBusy(dialog, busy, label, idleLabel) {
|
|
|
4652
5022
|
if (status) status.textContent = "";
|
|
4653
5023
|
if (busy) {
|
|
4654
5024
|
dialog._stillWorkingTimer = setTimeout(() => {
|
|
4655
|
-
if (status) status.textContent = "Still working.
|
|
5025
|
+
if (status) status.textContent = "Still working. Recalculating readiness and repository state.";
|
|
4656
5026
|
}, 1_500);
|
|
4657
5027
|
}
|
|
4658
5028
|
}
|
|
@@ -4860,7 +5230,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
4860
5230
|
.onboarding-git-status{display:flex;align-items:flex-start;gap:9px;margin:14px 25px 0;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.onboarding-git-status .status-dot{margin-top:4px}.onboarding-git-status strong,.onboarding-git-status small{display:block}.onboarding-git-status strong{font-size:12px}.onboarding-git-status small{color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:3px}.onboarding-git-status code{font-size:10.8px}
|
|
4861
5231
|
.badge.status-overdue,.badge.status-blocked{background:#f7dfdc;color:#873027}.badge.status-due{background:#f6e8c9;color:#79500f}.badge.status-upcoming,.badge.status-proposed{background:var(--accent-soft);color:var(--accent)}.badge.status-complete{background:#dcefe4;color:#125733}
|
|
4862
5232
|
.obligation-preview,.event-reminder-preview{display:grid;gap:8px}.obligation-preview a{display:flex;align-items:flex-start;gap:9px;text-decoration:none;padding:7px 0;border-top:1px solid var(--line)}.obligation-preview a:first-child{border-top:0;padding-top:0}.obligation-preview strong,.obligation-preview small,.event-reminder-preview strong,.event-reminder-preview small{display:block}.obligation-preview strong,.event-reminder-preview strong{font-size:12px}.obligation-preview small,.event-reminder-preview small{font-size:10.8px;color:var(--muted);margin-top:2px}.event-reminder-preview{grid-template-columns:repeat(2,minmax(0,1fr))}.event-reminder-preview a{padding:10px;border-radius:7px;background:var(--surface-soft);text-decoration:none}
|
|
4863
|
-
.obligation-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;align-items:start}.obligation-column{min-width:0}.obligation-column-head{display:flex;align-items:center;justify-content:space-between;margin:5px 1px 10px}.obligation-column-head>strong{font:500 26.4px Georgia,serif}.obligation-cards{display:grid;gap:9px}.obligation-card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--accent-light);border-radius:9px;padding:14px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.obligation-card.status-overdue,.obligation-card.status-blocked{border-left-color:var(--red)}.obligation-card.status-due{border-left-color:#d89021}.obligation-card-head{display:flex;justify-content:space-between;gap:8px;text-transform:uppercase;letter-spacing:.06em;font-size:9.6px;color:var(--muted)}.obligation-card-head strong{color:var(--ink);text-align:right}.obligation-card h3{font-size:14.4px;margin:9px 0 7px}.obligation-card h3 a{text-decoration:none}.obligation-card p{font-size:10.8px;line-height:1.5;color:var(--muted);margin:0}.obligation-links{margin-top:10px}.workflow-section{margin-top:30px}.section-head{display:flex;justify-content:space-between;margin-bottom:13px}.section-head h2{font:500 28.8px Georgia,serif;margin:6px 0}.section-head p:not(.kicker){font-size:13.2px;color:var(--muted);margin:0;max-width:720px}.policy-event-feedback{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:10px;align-items:center;margin-top:14px;padding:12px 14px;border:1px solid #9ccfb2;border-radius:8px;background:#e7f5ec}.policy-event-feedback .status-dot{align-self:start;margin-top:4px}.policy-event-feedback strong,.policy-event-feedback p{display:block}.policy-event-feedback strong{font-size:12px}.policy-event-feedback p{margin:3px 0 0;color:#315d44;font-size:10.8px}.policy-event-feedback .icon-button{width:30px;height:30px}.policy-event-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.policy-event-more{margin-top:10px}.policy-event-row{position:relative;display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.policy-event-row[hidden]{display:none}.policy-event-name{min-width:0}.policy-event-title{display:flex;align-items:center;gap:6px;min-width:0}.policy-event-title>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.policy-event-guide{display:inline-flex;flex:0 0 auto}.policy-event-guide .guide-trigger{width:20px;height:20px}.policy-event-guide .guide-trigger svg{width:14px;height:14px}.policy-event-name strong,.policy-event-name>small{display:block}.policy-event-name strong{font-size:12px}.policy-event-name>small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.policy-event-row>.button{flex:none;padding:7px 9px;font-size:10.8px}.policy-event-tooltip{position:absolute;z-index:8;top:calc(100% + 7px);left:0;width:min(420px,calc(100vw - 48px));padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow);opacity:0;visibility:hidden;transform:translateY(-3px);transition:opacity .12s,transform .12s,visibility 0s .12s;pointer-events:none}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:0;left:auto}.policy-event-row:hover,.policy-event-row:focus-within{z-index:9}.policy-event-guide:hover .policy-event-tooltip,.policy-event-guide:focus-within .policy-event-tooltip{opacity:1;visibility:visible;transform:none;transition-delay:0s}.policy-event-tooltip>strong{font-size:12px}.policy-event-tooltip ol{display:grid;gap:7px;margin:9px 0 0;padding-left:20px}.policy-event-tooltip li span,.policy-event-tooltip li small{display:block}.policy-event-tooltip li span{font-size:10.8px}.policy-event-tooltip li small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.4}
|
|
5233
|
+
.obligation-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;align-items:start}.obligation-column{min-width:0}.obligation-column-head{display:flex;align-items:center;justify-content:space-between;margin:5px 1px 10px}.obligation-column-head>strong{font:500 26.4px Georgia,serif}.obligation-cards{display:grid;gap:9px}.obligation-card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--accent-light);border-radius:9px;padding:14px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.obligation-card.status-overdue,.obligation-card.status-blocked{border-left-color:var(--red)}.obligation-card.status-due{border-left-color:#d89021}.obligation-card-head{display:flex;justify-content:space-between;gap:8px;text-transform:uppercase;letter-spacing:.06em;font-size:9.6px;color:var(--muted)}.obligation-card-head strong{color:var(--ink);text-align:right}.obligation-card h3{font-size:14.4px;margin:9px 0 7px}.obligation-card h3 a{text-decoration:none}.obligation-card p{font-size:10.8px;line-height:1.5;color:var(--muted);margin:0}.obligation-links{margin-top:10px}.workflow-section{margin-top:30px}.operation-gate{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px;border:1px solid var(--accent-light);border-radius:12px;background:var(--accent-wash)}.operation-gate h2{margin:5px 0 7px;font:500 25px Georgia,serif}.operation-gate p:not(.kicker){margin:0;max-width:680px;color:var(--muted)}.operation-setup-preview>summary{margin-top:18px;padding:14px 16px;border:1px solid var(--line);border-radius:9px;background:var(--panel);font-weight:700;cursor:pointer}.operation-setup-preview[open]>summary{margin-bottom:0}.section-head{display:flex;justify-content:space-between;margin-bottom:13px}.section-head h2{font:500 28.8px Georgia,serif;margin:6px 0}.section-head p:not(.kicker){font-size:13.2px;color:var(--muted);margin:0;max-width:720px}.policy-event-feedback{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:10px;align-items:center;margin-top:14px;padding:12px 14px;border:1px solid #9ccfb2;border-radius:8px;background:#e7f5ec}.policy-event-feedback .status-dot{align-self:start;margin-top:4px}.policy-event-feedback strong,.policy-event-feedback p{display:block}.policy-event-feedback strong{font-size:12px}.policy-event-feedback p{margin:3px 0 0;color:#315d44;font-size:10.8px}.policy-event-feedback .icon-button{width:30px;height:30px}.policy-event-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.policy-event-more{margin-top:10px}.policy-event-row{position:relative;display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.policy-event-row[hidden]{display:none}.policy-event-name{min-width:0}.policy-event-title{display:flex;align-items:center;gap:6px;min-width:0}.policy-event-title>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.policy-event-guide{display:inline-flex;flex:0 0 auto}.policy-event-guide .guide-trigger{width:20px;height:20px}.policy-event-guide .guide-trigger svg{width:14px;height:14px}.policy-event-name strong,.policy-event-name>small{display:block}.policy-event-name strong{font-size:12px}.policy-event-name>small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.policy-event-row>.button{flex:none;padding:7px 9px;font-size:10.8px}.policy-event-tooltip{position:absolute;z-index:8;top:calc(100% + 7px);left:0;width:min(420px,calc(100vw - 48px));padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow);opacity:0;visibility:hidden;transform:translateY(-3px);transition:opacity .12s,transform .12s,visibility 0s .12s;pointer-events:none}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:0;left:auto}.policy-event-row:hover,.policy-event-row:focus-within{z-index:9}.policy-event-guide:hover .policy-event-tooltip,.policy-event-guide:focus-within .policy-event-tooltip{opacity:1;visibility:visible;transform:none;transition-delay:0s}.policy-event-tooltip>strong{font-size:12px}.policy-event-tooltip ol{display:grid;gap:7px;margin:9px 0 0;padding-left:20px}.policy-event-tooltip li span,.policy-event-tooltip li small{display:block}.policy-event-tooltip li span{font-size:10.8px}.policy-event-tooltip li small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.4}
|
|
4864
5234
|
.obligation-card-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:9px;margin-top:10px}.obligation-card-foot .obligation-links{margin-top:0;min-width:0}.obligation-action{flex:0 0 auto;border:0;border-radius:6px;background:var(--accent-soft);color:var(--accent);padding:7px 9px;font-family:inherit;font-size:10.8px;font-weight:700;line-height:1;text-decoration:none;cursor:pointer}.obligation-action:hover{filter:brightness(1.08)}.obligation-action.blocked{background:var(--surface-muted);color:var(--muted)}.obligation-more{width:100%;margin-top:9px}.workflow-section{scroll-margin-top:92px}
|
|
4865
5235
|
.event-dialog label{display:block;margin-top:13px}.event-dialog label[hidden]{display:none}.event-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.event-dialog input,.event-dialog select,.event-dialog textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.event-dialog textarea{resize:vertical}.commit-dialog .form-grid label.full{grid-column:1/-1}.workflow-preview{margin-top:14px;padding:0 12px;border-radius:7px;background:var(--surface-soft)}.workflow-preview:not(:empty){padding-top:10px;padding-bottom:10px}.workflow-preview strong,.workflow-preview p{display:block;margin:0}.workflow-preview p{margin-top:5px;color:var(--muted);font-size:12px;line-height:1.5}.event-dialog-steps{display:grid;gap:6px;margin-top:15px;padding:10px;background:var(--surface-soft);border-radius:7px}.event-dialog-steps strong,.event-dialog-steps small{display:block}.event-dialog-steps strong{font-size:12px}.event-dialog-steps small{font-size:9.6px;color:var(--muted);margin-top:2px}
|
|
4866
5236
|
.applicability-dialog{width:min(980px,calc(100vw - 30px));max-height:calc(100vh - 32px);border:0;border-radius:12px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 25px 80px rgba(0,0,24,.28)}.applicability-dialog form{padding:23px}.applicability-dialog form>p{color:var(--muted);font-size:13.2px}.applicability-dialog form>.applicability-baseline-note{padding:10px 12px;border-radius:7px;background:var(--accent-soft);color:var(--ink)}.review-context label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.review-context input,.review-context select,.applicability-row input,.applicability-row select{width:100%;min-height:38px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:8px 9px;font-size:13.2px}.review-context label.full{grid-column:1/-1}.applicability-rows{display:grid;gap:7px;max-height:46vh;overflow:auto;margin-top:16px;padding-right:4px}.applicability-row{display:grid;grid-template-columns:minmax(210px,1fr) 180px minmax(240px,1.3fr);gap:9px;align-items:center;padding:9px;border:1px solid var(--line);border-radius:8px}.applicability-row strong,.applicability-row small{display:block}.applicability-row small{margin-top:3px;color:var(--muted);font-size:10.8px}.applicability-row .applicability-constraint{color:var(--accent);line-height:1.35}
|
|
@@ -4878,6 +5248,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
|
|
|
4878
5248
|
@media(max-width:520px){.applicability-dialog form{padding:18px}.applicability-row{grid-template-columns:1fr}.applicability-rows{max-height:42vh}}
|
|
4879
5249
|
@media(max-width:760px){.workflow-findings{grid-template-columns:1fr}}
|
|
4880
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}
|
|
4881
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%}}
|
|
4882
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}}
|
|
4883
5254
|
|