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/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
- if (!state.readOnly && rendererSettingsEntry()?.record.showOnboarding === true && !initialSetupSystem()) {
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
- if (route.name === "home") renderHome(main);
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 repositoryLabel = state.repository?.mode === "trunk"
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 = state.repository?.mode === "trunk"
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
- 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 ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
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)) queueMicrotask(() => openEditor(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
- : field.relation ? relationHelp(field)
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 (field.relation && field.type === "array") {
3687
- const candidates = relationCandidates(field);
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(field, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet.</div>';
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 (field.relation) {
3694
- const candidates = relationCandidates(field);
3900
+ if (relation) {
3901
+ const candidates = relationCandidates(relationField, type, name);
3695
3902
  control = candidates.length
3696
- ? '<select><option value="">Select ' + esc(relationTypeLabel(field).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>'
3697
- : required ? '<select><option value="">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet</option></select>' : '<div class="missing-options">No matching ' + esc(relationTypeLabel(field, true).toLowerCase()) + ' yet.</div>';
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
- return '<div class="string-map-editor"><div class="string-map-items">' + (entries.length ? entries.map(row).join("") : row()) + '</div><button type="button" class="button" data-add-string-map>Add ' + esc(humanize(name).replace(/s$/, "").toLowerCase()) + '</button><template>' + row() + '</template></div>';
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 }) => field.relation.includes("*") || field.relation.includes(record.type));
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