filegrc 0.15.0 → 0.15.2
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/v11.json +13 -20
- package/package.json +1 -1
- package/src/audit-preparation.js +121 -14
- package/src/audit-selection.js +33 -0
- package/src/cli.js +13 -3
- package/src/collection-review.js +124 -31
- package/src/collection-revision.js +6 -1
- package/src/collection-scope.js +121 -5
- package/src/files.js +13 -1
- package/src/git.js +86 -0
- package/src/index.js +3 -2
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +287 -34
- package/src/retention-schedule-approval.js +36 -17
- package/src/retention.js +81 -23
- package/src/server.js +15 -1
- package/src/source-coverage.js +7 -0
- package/src/state.js +2 -0
- package/src/validate.js +33 -3
- package/src/web.js +264 -134
- package/src/workflow.js +5 -1
package/src/web.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createResourceId } from "./id.js";
|
|
2
|
+
import { selectDefaultAudit } from "./audit-selection.js";
|
|
2
3
|
import { MODEL_CAPABILITY_VERSIONS } from "../model/index.js";
|
|
3
4
|
import {
|
|
4
5
|
calendarOccurrence,
|
|
@@ -14,12 +15,20 @@ import { formatCalendarDate, formatLocalDateTime } from "./time.js";
|
|
|
14
15
|
|
|
15
16
|
export function dashboardProgramReadiness(programReadiness = {}) {
|
|
16
17
|
const progress = programReadiness.progress || {};
|
|
17
|
-
const lifecycle =
|
|
18
|
-
? {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
const lifecycle = progress.mode === "operating-window"
|
|
19
|
+
? {
|
|
20
|
+
status: progress.status || (programReadiness.operating ? "Operating" : "Needs attention"),
|
|
21
|
+
tone: programReadiness.operating ? "good" : "warn"
|
|
22
|
+
}
|
|
23
|
+
: programReadiness.operating
|
|
24
|
+
? { status: "Operating", tone: "good" }
|
|
25
|
+
: programReadiness.evidenceReady
|
|
26
|
+
? { status: progress.status || "Evidence ready", tone: "good" }
|
|
27
|
+
: { status: progress.status || "Needs work", tone: "warn" };
|
|
22
28
|
return {
|
|
29
|
+
mode: progress.mode || "setup-progress",
|
|
30
|
+
label: progress.label || "Program setup",
|
|
31
|
+
detail: progress.detail || `${progress.complete ?? 0} of ${progress.total ?? 0} required actions complete.`,
|
|
23
32
|
percent: progress.percent ?? 0,
|
|
24
33
|
complete: progress.complete ?? 0,
|
|
25
34
|
total: progress.total ?? 0,
|
|
@@ -27,6 +36,23 @@ export function dashboardProgramReadiness(programReadiness = {}) {
|
|
|
27
36
|
};
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
export function workflowHrefBelongsToPage(destination = {}, href = "") {
|
|
40
|
+
const route = String(href).split("?")[0];
|
|
41
|
+
if (!route) return false;
|
|
42
|
+
if (destination.href === "#/policies") {
|
|
43
|
+
return ["policy", "document", "training"].some((type) => route.startsWith(`#/resource/${type}/`));
|
|
44
|
+
}
|
|
45
|
+
if (destination.type) {
|
|
46
|
+
return route === `#/resources/${destination.type}` || route.startsWith(`#/resource/${destination.type}/`);
|
|
47
|
+
}
|
|
48
|
+
return route === String(destination.href || "").split("?")[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pageActionIdentity(href = "", fallback = "") {
|
|
52
|
+
const route = String(href).split("?")[0];
|
|
53
|
+
return route.startsWith("#/resource/") ? route : fallback || href;
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export function renderIndex(state = null) {
|
|
31
57
|
const snapshot = state
|
|
32
58
|
? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
|
|
@@ -71,6 +97,9 @@ let READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
|
|
|
71
97
|
}));
|
|
72
98
|
const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
|
|
73
99
|
const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
|
|
100
|
+
const workflowHrefBelongsToPage = ${workflowHrefBelongsToPage.toString()};
|
|
101
|
+
const pageActionIdentity = ${pageActionIdentity.toString()};
|
|
102
|
+
const selectDefaultAudit = ${selectDefaultAudit.toString()};
|
|
74
103
|
const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
|
|
75
104
|
...RESOURCE_PAGE_SUMMARIES,
|
|
76
105
|
"utility:evidence-sources": "Check that each Control has an authoritative, retrievable evidence source.",
|
|
@@ -93,6 +122,7 @@ let onboardingPendingDraft = false;
|
|
|
93
122
|
const resourceDetailRequests = new Map();
|
|
94
123
|
const stateSectionRequests = new Map();
|
|
95
124
|
let resourceGuideCleanup = null;
|
|
125
|
+
let recordOverflowCleanup = null;
|
|
96
126
|
let repositorySyncPollTimer = null;
|
|
97
127
|
let repositorySyncPollInFlight = false;
|
|
98
128
|
let mutationStateRefreshInFlight = false;
|
|
@@ -165,6 +195,8 @@ function handleRouteChange() {
|
|
|
165
195
|
function render() {
|
|
166
196
|
resourceGuideCleanup?.();
|
|
167
197
|
resourceGuideCleanup = null;
|
|
198
|
+
recordOverflowCleanup?.();
|
|
199
|
+
recordOverflowCleanup = null;
|
|
168
200
|
const previousNavigation = root.querySelector(".sidebar-nav");
|
|
169
201
|
if (previousNavigation) navigationScrollTop = previousNavigation.scrollTop;
|
|
170
202
|
const route = parseRoute();
|
|
@@ -460,8 +492,16 @@ function topbarProgramReadiness() {
|
|
|
460
492
|
return '<a class="topbar-readiness' + (loading ? ' is-loading' : '') + '" href="#/"' + (loading ? ' aria-busy="true"' : '') + '><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>';
|
|
461
493
|
}
|
|
462
494
|
const progress = dashboardProgramReadiness(state.programReadiness);
|
|
463
|
-
const
|
|
464
|
-
|
|
495
|
+
const setupRemaining = state.programReadiness.setupProgress?.remaining || 0;
|
|
496
|
+
const setupRegression = progress.mode === "operating-window" && setupRemaining > 0;
|
|
497
|
+
const href = progress.mode === "operating-window" && !setupRegression ? "#/stage/run" : nextProgramStageHref();
|
|
498
|
+
const label = setupRegression
|
|
499
|
+
? progress.label + " · " + setupRemaining + " setup " + (setupRemaining === 1 ? "action" : "actions")
|
|
500
|
+
: progress.label;
|
|
501
|
+
const detail = setupRegression
|
|
502
|
+
? progress.detail + " " + setupRemaining + " required setup " + (setupRemaining === 1 ? "action also needs" : "actions also need") + " attention."
|
|
503
|
+
: progress.detail;
|
|
504
|
+
return '<a class="topbar-readiness" href="' + href + '" aria-label="' + esc(label + ": " + progress.percent + "%. " + detail + " " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>' + esc(label) + '</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
|
|
465
505
|
}
|
|
466
506
|
|
|
467
507
|
function repositoryStatusTone(status) {
|
|
@@ -599,7 +639,11 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
599
639
|
const documentEntry = documentEntries.length === 1 ? documentEntries[0] : null;
|
|
600
640
|
const rows = resourcesOfType("retention-schedule-item")
|
|
601
641
|
.sort((left, right) => left.record.title.localeCompare(right.record.title));
|
|
602
|
-
const
|
|
642
|
+
const completedProposalIds = new Set(state.programReadiness?.stages
|
|
643
|
+
?.find(({ id }) => id === "policies")
|
|
644
|
+
?.items.filter(({ id, status }) => id.startsWith("retention-rule-") && status === "complete")
|
|
645
|
+
.map(({ id }) => id.slice("retention-rule-".length)) || []);
|
|
646
|
+
const proposedRows = rows.filter(({ record }) => completedProposalIds.has(record.id)).length;
|
|
603
647
|
const documentReadiness = documentEntry ? retentionScheduleDocumentReadiness(documentEntry.record.id) : null;
|
|
604
648
|
const documentReady = documentEntries.length === 1 && documentReadiness?.status === "complete";
|
|
605
649
|
const documentAction = documentEntry
|
|
@@ -616,7 +660,10 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
616
660
|
const cutoff = retentionCutoffLabel(record.cutoff);
|
|
617
661
|
const period = retentionPeriodLabel(record.retentionPeriod);
|
|
618
662
|
const action = record.dispositionAction ? properCase(record.dispositionAction) : "Not selected";
|
|
619
|
-
|
|
663
|
+
const status = record.status === "active" && state.collectionReviews?.["retention-schedule-item"]?.status !== "current"
|
|
664
|
+
? "proposed"
|
|
665
|
+
: displayStatus(record);
|
|
666
|
+
return '<tr data-retention-row data-history="' + (["superseded", "retired"].includes(record.status) ? "true" : "false") + '"><td data-label="Rule" data-primary-field><a class="record-title" href="#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies">' + esc(record.title) + '</a><small>' + esc(record.description || record.id) + '</small></td><td data-label="Information types">' + esc(informationTypes) + '</td><td data-label="Scope">' + esc(scope) + '</td><td data-label="Retention"><strong>' + esc(period) + '</strong><small>From ' + esc(cutoff) + '</small></td><td data-label="Disposition">' + esc(action) + '</td><td data-label="Status">' + formatValue(status, "status", record.type, true) + '</td><td data-label="Next action">' + recordWorkflowCell("retention-schedule-item", entry, "?stage=policies") + '</td></tr>';
|
|
620
667
|
}).join("")
|
|
621
668
|
: '<tr><td colspan="7">' + empty("No retention rules are needed until an actual information use requires one.") + '</td></tr>';
|
|
622
669
|
const add = state.readOnly ? "" : '<button class="button primary" type="button" data-add-retention-row>Add schedule row</button>';
|
|
@@ -625,12 +672,12 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
625
672
|
const showHistory = params.get("history") === "1";
|
|
626
673
|
const tableTools = '<div class="retention-table-tools"><label><span class="sr-only">Filter schedule rows</span><input type="search" data-retention-search placeholder="Filter schedule rows" value="' + esc(initialFilter) + '"></label><label class="history-toggle"><input type="checkbox" data-retention-history ' + (showHistory ? "checked" : "") + '> Show retired</label></div>';
|
|
627
674
|
main.innerHTML = '<div class="page retention-schedule-page"><nav class="breadcrumbs"><a href="#/stage/policies">Step 2</a><span>/</span><span>Data Retention Schedule</span></nav>' +
|
|
628
|
-
'<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>
|
|
629
|
-
'<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' +
|
|
630
|
-
'<section class="retention-rows"><div class="section-head"><div><p class="kicker">Structured schedule</p><h2>Schedule rows</h2><p>Add a row when an Information Type and its operational scope need a distinct cutoff, period, or disposition rule.</p></div>' + tableTools + '</div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Rule</th><th>Information types</th><th>Scope</th><th>Retention</th><th>Disposition</th><th>Status</th><th>Next action</th></tr></thead><tbody>' + rowHtml + '</tbody></table></div><p class="retention-filter-empty" data-retention-empty hidden>No schedule rows match this filter.</p><p class="retention-row-count" data-retention-count aria-live="polite"></p></section>' +
|
|
675
|
+
'<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>Owners finish each proposed retention row first. A separate reviewer then approves the complete document and all proposed rows once.</p></div><div class="actions">' + documentAction + add + '</div></div>' + resourceGuide("retention-schedule-item") +
|
|
676
|
+
'<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' + proposedRows + ' completed ' + pluralize("proposal", proposedRows) + '</strong><small>Final approval follows every row proposal</small></span><span class="badge ' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "good" : "warn") + '">' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "Approved" : "Approval needed") + '</span></div></div>' +
|
|
677
|
+
'<section class="retention-rows"><div class="section-head"><div><p class="kicker">Structured schedule</p><h2>Schedule rows</h2><p>Add a row when an Information Type and its operational scope need a distinct cutoff, period, or disposition rule.</p></div>' + tableTools + '</div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Rule</th><th>Information types</th><th>Scope</th><th>Retention</th><th>Disposition</th><th>Status</th><th>Next action</th></tr></thead><tbody>' + rowHtml + '</tbody></table></div><p class="retention-filter-empty" data-retention-empty hidden>No schedule rows match this filter.</p><p class="retention-row-count" data-retention-count aria-live="polite"></p><nav class="pagination retention-pagination" aria-label="Data Retention Schedule pages" hidden><button class="button" type="button" data-retention-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-retention-page="next">Next</button></nav></section>' +
|
|
631
678
|
renderRetentionScheduleIssues() + collectionReviewPanel("retention-schedule-item", true) + '</div>';
|
|
632
679
|
resourceGuideCleanup = setupResourceGuide(main);
|
|
633
|
-
wireRetentionScheduleTable(rows.length);
|
|
680
|
+
wireRetentionScheduleTable(rows.length, Number(params.get("page")) || 1);
|
|
634
681
|
main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("retention-schedule-item"));
|
|
635
682
|
main.querySelector("[data-add-retention-document]")?.addEventListener("click", () => openEditor("document", null, {
|
|
636
683
|
seed: {
|
|
@@ -649,7 +696,7 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
649
696
|
const seed = {
|
|
650
697
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
651
698
|
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
652
|
-
...(params.
|
|
699
|
+
...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
|
|
653
700
|
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
654
701
|
};
|
|
655
702
|
history.replaceState(null, "", "#/retention-schedule");
|
|
@@ -668,54 +715,61 @@ function openRetentionScheduleItemEditor(seed = {}) {
|
|
|
668
715
|
...(scheduleDocuments.length === 1 ? { scheduleDocumentId: scheduleDocuments[0].record.id } : {}),
|
|
669
716
|
...seed
|
|
670
717
|
},
|
|
671
|
-
description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, and
|
|
718
|
+
description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, owner review, and current revision bindings are complete.",
|
|
672
719
|
saveLabel: "Save schedule row"
|
|
673
720
|
});
|
|
674
721
|
}
|
|
675
722
|
|
|
676
|
-
function wireRetentionScheduleTable(total) {
|
|
723
|
+
function wireRetentionScheduleTable(total, initialPage = 1) {
|
|
677
724
|
const table = root.querySelector(".retention-rows");
|
|
678
725
|
if (!table) return;
|
|
679
726
|
const search = table.querySelector("[data-retention-search]");
|
|
680
727
|
const historyToggle = table.querySelector("[data-retention-history]");
|
|
681
728
|
const count = table.querySelector("[data-retention-count]");
|
|
682
729
|
const noResults = table.querySelector("[data-retention-empty]");
|
|
730
|
+
const pagination = table.querySelector(".retention-pagination");
|
|
731
|
+
const pageStatus = pagination.querySelector(".page-status");
|
|
732
|
+
const previous = pagination.querySelector('[data-retention-page="previous"]');
|
|
733
|
+
const next = pagination.querySelector('[data-retention-page="next"]');
|
|
734
|
+
let pageNumber = Number.isInteger(initialPage) && initialPage > 0 ? initialPage : 1;
|
|
683
735
|
const filter = () => {
|
|
684
736
|
const query = search.value.trim().toLowerCase();
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
737
|
+
const filtered = [...table.querySelectorAll("[data-retention-row]")].filter((row) => (
|
|
738
|
+
(historyToggle.checked || row.dataset.history !== "true")
|
|
739
|
+
&& (!query || row.textContent.toLowerCase().includes(query))
|
|
740
|
+
));
|
|
741
|
+
const totalPages = Math.max(1, Math.ceil(filtered.length / LIST_PAGE_SIZE));
|
|
742
|
+
pageNumber = Math.min(pageNumber, totalPages);
|
|
743
|
+
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
744
|
+
const visible = new Set(filtered.slice(start, start + LIST_PAGE_SIZE));
|
|
745
|
+
table.querySelectorAll("[data-retention-row]").forEach((row) => { row.hidden = !visible.has(row); });
|
|
746
|
+
noResults.hidden = filtered.length !== 0 || total === 0;
|
|
747
|
+
const firstVisible = filtered.length ? start + 1 : 0;
|
|
748
|
+
const lastVisible = Math.min(start + LIST_PAGE_SIZE, filtered.length);
|
|
749
|
+
count.textContent = firstVisible + "–" + lastVisible + " of " + filtered.length + " " + pluralize("row", filtered.length) + (filtered.length === total ? "" : " · " + total + " total");
|
|
750
|
+
pagination.hidden = totalPages === 1;
|
|
751
|
+
pageStatus.textContent = "Page " + pageNumber + " of " + totalPages;
|
|
752
|
+
previous.disabled = pageNumber === 1;
|
|
753
|
+
next.disabled = pageNumber === totalPages;
|
|
694
754
|
};
|
|
695
755
|
const updateLocation = () => {
|
|
696
756
|
const params = new URLSearchParams();
|
|
697
757
|
if (search.value.trim()) params.set("q", search.value.trim());
|
|
698
758
|
if (historyToggle.checked) params.set("history", "1");
|
|
759
|
+
if (pageNumber > 1) params.set("page", String(pageNumber));
|
|
699
760
|
history.replaceState(null, "", "#/retention-schedule" + (params.size ? "?" + params : ""));
|
|
700
761
|
};
|
|
701
|
-
search.addEventListener("input", () => { filter(); updateLocation(); });
|
|
702
|
-
historyToggle.addEventListener("change", () => { filter(); updateLocation(); });
|
|
762
|
+
search.addEventListener("input", () => { pageNumber = 1; filter(); updateLocation(); });
|
|
763
|
+
historyToggle.addEventListener("change", () => { pageNumber = 1; filter(); updateLocation(); });
|
|
764
|
+
previous.addEventListener("click", () => { pageNumber -= 1; filter(); updateLocation(); });
|
|
765
|
+
next.addEventListener("click", () => { pageNumber += 1; filter(); updateLocation(); });
|
|
703
766
|
filter();
|
|
704
767
|
}
|
|
705
768
|
|
|
706
769
|
function renderRetentionScheduleIssues() {
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
.map(({ record }) => ({
|
|
711
|
-
id: "planned-" + record.id,
|
|
712
|
-
title: "Complete or retire " + record.title,
|
|
713
|
-
message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
|
|
714
|
-
href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
|
|
715
|
-
}));
|
|
716
|
-
const actions = [...plannedRows, ...items.filter((item) => (
|
|
717
|
-
item.status === "action" && item.id !== "collection-review-retention-schedule-item"
|
|
718
|
-
))];
|
|
770
|
+
const actions = retentionSchedulePageItems().filter((item) => (
|
|
771
|
+
item.id !== "collection-review-retention-schedule-item"
|
|
772
|
+
));
|
|
719
773
|
if (!actions.length) return "";
|
|
720
774
|
return '<section class="retention-issues"><div class="section-head"><div><p class="kicker">Before approval</p><h2>Items to resolve</h2></div></div><div class="retention-issue-list">' + actions.map((item) => {
|
|
721
775
|
const href = item.href || (item.id?.startsWith("retention-use-")
|
|
@@ -728,9 +782,16 @@ function renderRetentionScheduleIssues() {
|
|
|
728
782
|
}
|
|
729
783
|
|
|
730
784
|
function retentionScheduleDocumentReadiness(documentId) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
785
|
+
const documentIssueCodes = new Set([
|
|
786
|
+
"missing-retention-schedule-document",
|
|
787
|
+
"multiple-retention-schedule-documents",
|
|
788
|
+
"incomplete-retention-schedule-document"
|
|
789
|
+
]);
|
|
790
|
+
const issue = state.collectionReviews?.["retention-schedule-item"]?.approvalIssues
|
|
791
|
+
?.find(({ code }) => documentIssueCodes.has(code));
|
|
792
|
+
return issue
|
|
793
|
+
? { status: "action", message: issue.message }
|
|
794
|
+
: { status: "complete", message: "The document proposal is complete. Final approval follows all completed row proposals." };
|
|
734
795
|
}
|
|
735
796
|
|
|
736
797
|
function referenceTitle(id) {
|
|
@@ -888,7 +949,7 @@ function renderDocumentActivationAssessments() {
|
|
|
888
949
|
|
|
889
950
|
function renderAuditDocumentActivationAssessments() {
|
|
890
951
|
const audits = resourcesOfType("audit").map(({ record }) => record);
|
|
891
|
-
const audit = audits.
|
|
952
|
+
const audit = selectDefaultAudit(audits, state.programReadiness?.asOf || currentDate());
|
|
892
953
|
const preparation = audit ? state.auditPreparations?.[audit.id] : null;
|
|
893
954
|
const assessments = preparation?.documentActivations || [];
|
|
894
955
|
if (!audit || !assessments.length) return "";
|
|
@@ -1131,27 +1192,34 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1131
1192
|
const current = assessment.status === "current";
|
|
1132
1193
|
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1133
1194
|
const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
|
|
1195
|
+
const proposalBlocker = assessment.incompleteRecordProposals?.length
|
|
1196
|
+
? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review."
|
|
1197
|
+
: null;
|
|
1198
|
+
const reviewBlocker = scheduleBlocker || proposalBlocker;
|
|
1134
1199
|
const needsFirstRecord = collectionNeedsFirstRecord(type);
|
|
1135
1200
|
const reviewerNames = (assessment.review?.reviewedByIds || [])
|
|
1136
1201
|
.map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
|
|
1137
1202
|
const reviewNote = assessment.review?.rationale
|
|
1138
1203
|
? " Note: " + esc(assessment.review.rationale)
|
|
1139
1204
|
: "";
|
|
1140
|
-
const reviewSummary = current && !
|
|
1205
|
+
const reviewSummary = current && !reviewBlocker
|
|
1141
1206
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + "." + reviewNote + '</span></p>'
|
|
1142
|
-
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(
|
|
1143
|
-
const compactReviewSummary = current && !
|
|
1207
|
+
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : proposalBlocker ? "Complete proposals first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(reviewBlocker || assessment.message) + '</span></p>';
|
|
1208
|
+
const compactReviewSummary = current && !reviewBlocker
|
|
1144
1209
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + ".</span></p>"
|
|
1145
1210
|
: reviewSummary;
|
|
1146
1211
|
const action = state.readOnly
|
|
1147
1212
|
? ""
|
|
1148
|
-
:
|
|
1149
|
-
? '<button class="button" type="button" disabled title="' + esc(
|
|
1213
|
+
: reviewBlocker
|
|
1214
|
+
? '<button class="button" type="button" disabled title="' + esc(reviewBlocker) + '">' + (scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>'
|
|
1150
1215
|
: needsFirstRecord
|
|
1151
1216
|
? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
|
|
1152
1217
|
: '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? scheduleReview ? "Approve a new revision" : "Review again" : scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>';
|
|
1153
|
-
const status =
|
|
1154
|
-
const
|
|
1218
|
+
const status = reviewBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
1219
|
+
const proposalSummary = assessment.recordProposals?.length
|
|
1220
|
+
? '<p class="collection-review-proposal-summary"><strong>' + assessment.recordProposals.filter(({ complete }) => complete).length + ' of ' + assessment.recordProposals.length + ' record proposals complete.</strong> The collection review follows every proposal.</p>'
|
|
1221
|
+
: "";
|
|
1222
|
+
const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? scheduleReview ? "Show approved revision" : "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">' + (scheduleReview ? "Schedule approval" : "Scope confirmation") + '</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div>' + proposalSummary + '<ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
|
|
1155
1223
|
const summary = current
|
|
1156
1224
|
? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">' + (scheduleReview ? "Approved schedule" : "Scope confirmation") + '</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
|
|
1157
1225
|
: details;
|
|
@@ -1170,15 +1238,8 @@ function retentionScheduleApprovalBlocker() {
|
|
|
1170
1238
|
const document = documents.length === 1 ? documents[0] : null;
|
|
1171
1239
|
if (documents.length > 1) return "Keep exactly one current program Data Retention Schedule document before approval.";
|
|
1172
1240
|
if (!document) return "Add the governing Data Retention Schedule document first.";
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
}
|
|
1176
|
-
const documentReadiness = retentionScheduleDocumentReadiness(document.id);
|
|
1177
|
-
if (documentReadiness?.status !== "complete") {
|
|
1178
|
-
return documentReadiness?.message || "Complete the governing document before approving the schedule.";
|
|
1179
|
-
}
|
|
1180
|
-
const planned = resourcesOfType("retention-schedule-item").find(({ record }) => record.status === "planned")?.record;
|
|
1181
|
-
if (planned) return "Complete or retire the planned row: " + planned.title + ".";
|
|
1241
|
+
const approvalIssue = state.collectionReviews?.["retention-schedule-item"]?.approvalIssues?.[0];
|
|
1242
|
+
if (approvalIssue) return approvalIssue.message;
|
|
1182
1243
|
const incomplete = retentionReviewItems("policies", false).find((item) => (
|
|
1183
1244
|
item.status === "action" && item.id !== "collection-review-retention-schedule-item"
|
|
1184
1245
|
));
|
|
@@ -1276,8 +1337,7 @@ function recordWorkflowItems(type, id) {
|
|
|
1276
1337
|
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
1277
1338
|
return [
|
|
1278
1339
|
...(state.workflow?.findings || []),
|
|
1279
|
-
...(state.workflow?.workItems || [])
|
|
1280
|
-
...programReadinessWorkflowItems()
|
|
1340
|
+
...(state.workflow?.workItems || [])
|
|
1281
1341
|
].filter((item) => (
|
|
1282
1342
|
activeStates.has(item.state)
|
|
1283
1343
|
&& (
|
|
@@ -1499,7 +1559,10 @@ function workflowItemHref(item) {
|
|
|
1499
1559
|
function retentionScheduleItemHref(item) {
|
|
1500
1560
|
const params = new URLSearchParams({ new: "1" });
|
|
1501
1561
|
if (item.informationTypeId) params.set("informationTypeId", item.informationTypeId);
|
|
1502
|
-
|
|
1562
|
+
for (const id of item.retentionScopeResourceIds || []) params.append("scopeResourceId", id);
|
|
1563
|
+
if (!params.has("scopeResourceId") && (item.resourceId || item.subject?.id)) {
|
|
1564
|
+
params.set("scopeResourceId", item.resourceId || item.subject.id);
|
|
1565
|
+
}
|
|
1503
1566
|
const informationTypeTitle = String(item.title || "").replace(/^Decide retention for\s+/i, "").trim();
|
|
1504
1567
|
if (informationTypeTitle && informationTypeTitle !== item.title) params.set("title", "Retention for " + informationTypeTitle);
|
|
1505
1568
|
const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
|
|
@@ -1611,16 +1674,50 @@ function isRetentionScheduleWorkflowItem(item) {
|
|
|
1611
1674
|
});
|
|
1612
1675
|
}
|
|
1613
1676
|
|
|
1614
|
-
function
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
.
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1677
|
+
function retentionSchedulePageItems() {
|
|
1678
|
+
const plannedRows = resourcesOfType("retention-schedule-item")
|
|
1679
|
+
.filter(({ record }) => record.status === "planned")
|
|
1680
|
+
.map(({ record }) => ({
|
|
1681
|
+
id: "planned-" + record.id,
|
|
1682
|
+
key: "planned-" + record.id,
|
|
1683
|
+
status: "action",
|
|
1621
1684
|
state: "ready",
|
|
1622
|
-
|
|
1685
|
+
title: "Complete or retire " + record.title,
|
|
1686
|
+
message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
|
|
1687
|
+
resourceType: record.type,
|
|
1688
|
+
resourceId: record.id,
|
|
1689
|
+
href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
|
|
1623
1690
|
}));
|
|
1691
|
+
return distinctPageActionItems([
|
|
1692
|
+
...plannedRows,
|
|
1693
|
+
...retentionReviewItems("policies", false)
|
|
1694
|
+
.filter(({ status }) => status === "action")
|
|
1695
|
+
.map((item) => ({
|
|
1696
|
+
...item,
|
|
1697
|
+
key: item.id,
|
|
1698
|
+
state: "ready",
|
|
1699
|
+
href: item.id === "collection-review-retention-schedule-item"
|
|
1700
|
+
? "#/retention-schedule"
|
|
1701
|
+
: item.id?.startsWith("retention-use-")
|
|
1702
|
+
? retentionScheduleItemHref(item)
|
|
1703
|
+
: item.resourceId && item.resourceType
|
|
1704
|
+
? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
|
|
1705
|
+
: workflowItemHref(item) || "#/retention-schedule",
|
|
1706
|
+
subject: item.resourceId && item.resourceType
|
|
1707
|
+
? { type: item.resourceType, id: item.resourceId }
|
|
1708
|
+
: item.subject
|
|
1709
|
+
}))
|
|
1710
|
+
]);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function distinctPageActionItems(items) {
|
|
1714
|
+
const distinct = new Map();
|
|
1715
|
+
for (const item of items) {
|
|
1716
|
+
const href = item.href || workflowItemHref(item);
|
|
1717
|
+
const key = pageActionIdentity(href, item.key || item.id);
|
|
1718
|
+
if (!distinct.has(key)) distinct.set(key, item);
|
|
1719
|
+
}
|
|
1720
|
+
return [...distinct.values()];
|
|
1624
1721
|
}
|
|
1625
1722
|
|
|
1626
1723
|
function evidenceSourceCheckLabel(name) {
|
|
@@ -1660,11 +1757,11 @@ function stagePageCard(stage, destination, index) {
|
|
|
1660
1757
|
const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
|
|
1661
1758
|
const derived = derivedStagePageState(stage, destination);
|
|
1662
1759
|
const complete = derived.complete;
|
|
1663
|
-
const items =
|
|
1760
|
+
const items = stagePageActionItems(stage, destination);
|
|
1664
1761
|
const completionState = '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + esc(derived.label) + '</span>';
|
|
1665
1762
|
const taskPreview = items.length
|
|
1666
1763
|
? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
|
|
1667
|
-
const href = destination.utility === "evidence-sources" ? destination.href : workflowItemHref(item) || destination.href;
|
|
1764
|
+
const href = destination.utility === "evidence-sources" ? destination.href : item.href || workflowItemHref(item) || destination.href;
|
|
1668
1765
|
return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
|
|
1669
1766
|
}).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
|
|
1670
1767
|
: "";
|
|
@@ -1699,6 +1796,11 @@ function stageProgress(stage) {
|
|
|
1699
1796
|
return progressFromCounts(complete, applicable.length, "page");
|
|
1700
1797
|
}
|
|
1701
1798
|
|
|
1799
|
+
function stagePageActionItems(stage, destination) {
|
|
1800
|
+
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1801
|
+
return stagePageItems(stage, destination).filter(({ state }) => !deferredStates.has(state));
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1702
1804
|
function derivedStagePageState(stage, destination) {
|
|
1703
1805
|
if (
|
|
1704
1806
|
stage.id === "audit"
|
|
@@ -1716,15 +1818,14 @@ function derivedStagePageState(stage, destination) {
|
|
|
1716
1818
|
: { complete: evidenceItems.length > 0, label: evidenceItems.length ? "Ready" : "Not configured", countsTowardProgress: evidenceItems.length > 0 };
|
|
1717
1819
|
}
|
|
1718
1820
|
if (destination.utility === "retention-schedule") {
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
return assessment?.status === "current" && !incomplete
|
|
1821
|
+
const incomplete = retentionSchedulePageItems().length;
|
|
1822
|
+
return !incomplete
|
|
1722
1823
|
? { complete: true, label: "Approved" }
|
|
1723
1824
|
: { complete: false, label: incomplete ? incomplete + " " + pluralize("item", incomplete) + " need work" : "Approval needed" };
|
|
1724
1825
|
}
|
|
1725
1826
|
const items = stagePageItems(stage, destination);
|
|
1726
1827
|
const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
|
|
1727
|
-
const blocking =
|
|
1828
|
+
const blocking = stagePageActionItems(stage, destination);
|
|
1728
1829
|
if (blocking.length) {
|
|
1729
1830
|
return { complete: false, label: blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work") };
|
|
1730
1831
|
}
|
|
@@ -1749,8 +1850,7 @@ function stagePageItems(stage, destination) {
|
|
|
1749
1850
|
const activeStates = new Set(["blocked", "due", "later", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
|
|
1750
1851
|
const items = [
|
|
1751
1852
|
...(state.workflow?.findings || []),
|
|
1752
|
-
...(state.workflow?.workItems || [])
|
|
1753
|
-
...programReadinessWorkflowItems()
|
|
1853
|
+
...(state.workflow?.workItems || [])
|
|
1754
1854
|
].filter((item) => (
|
|
1755
1855
|
activeStates.has(item.state)
|
|
1756
1856
|
&& (
|
|
@@ -1766,18 +1866,9 @@ function stagePageItems(stage, destination) {
|
|
|
1766
1866
|
.map((item) => ({ ...item, key: item.id, state: item.status, subject: { type: "source-coverage" } }));
|
|
1767
1867
|
}
|
|
1768
1868
|
if (destination.utility === "retention-schedule") {
|
|
1769
|
-
return
|
|
1770
|
-
.filter(({ status }) => status !== "complete")
|
|
1771
|
-
.map((item) => ({ ...item, key: item.id, state: item.status, subject: item.resourceId && item.resourceType ? { type: item.resourceType, id: item.resourceId } : { type: item.resourceType || "retention-schedule-item" } }));
|
|
1772
|
-
}
|
|
1773
|
-
if (stage.id === "policies" && destination.href === "#/policies") {
|
|
1774
|
-
return items.filter((item) => !isRetentionScheduleWorkflowItem(item)).sort((left, right) => (
|
|
1775
|
-
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1776
|
-
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1777
|
-
|| left.key.localeCompare(right.key)
|
|
1778
|
-
));
|
|
1869
|
+
return retentionSchedulePageItems();
|
|
1779
1870
|
}
|
|
1780
|
-
return items.filter((item) => {
|
|
1871
|
+
return distinctPageActionItems(items.filter((item) => {
|
|
1781
1872
|
if (destination.type === "program" && item.key === "program.scope.criteria") return false;
|
|
1782
1873
|
if (
|
|
1783
1874
|
destination.type === "requirement"
|
|
@@ -1789,22 +1880,10 @@ function stagePageItems(stage, destination) {
|
|
|
1789
1880
|
&& ["source-coverage", "system"].includes(item.subject?.type)
|
|
1790
1881
|
&& item.key?.startsWith("evidence-source.")
|
|
1791
1882
|
) return false;
|
|
1792
|
-
if (
|
|
1793
|
-
stage.id === "policies"
|
|
1794
|
-
&& destination.type === "policy"
|
|
1795
|
-
&& item.code === "governance.appointment.independent-policy-reviewer"
|
|
1796
|
-
) return true;
|
|
1797
|
-
if (
|
|
1798
|
-
destination.type
|
|
1799
|
-
&& (item.subject?.type === destination.type || item.source?.type === destination.type)
|
|
1800
|
-
) return true;
|
|
1801
1883
|
const href = workflowItemHref(item);
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|| href.startsWith(destinationHref + "?")
|
|
1806
|
-
|| Boolean(destination.type && href.startsWith("#/resource/" + destination.type + "/"));
|
|
1807
|
-
}).sort((left, right) => (
|
|
1884
|
+
return workflowHrefBelongsToPage(destination, href)
|
|
1885
|
+
&& !(destination.href === "#/policies" && isRetentionScheduleWorkflowItem(item));
|
|
1886
|
+
})).sort((left, right) => (
|
|
1808
1887
|
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|
|
1809
1888
|
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|
|
1810
1889
|
|| left.key.localeCompare(right.key)
|
|
@@ -1820,7 +1899,7 @@ function operationProgress() {
|
|
|
1820
1899
|
const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
|
|
1821
1900
|
const asOf = program?.asOf || currentDate();
|
|
1822
1901
|
const candidateStarted = goal === "soc-2-type-2"
|
|
1823
|
-
? Boolean(program?.
|
|
1902
|
+
? Boolean(program?.progress?.mode === "operating-window")
|
|
1824
1903
|
: goal === "soc-2-type-1"
|
|
1825
1904
|
? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
|
|
1826
1905
|
: Boolean(program?.evidenceReady);
|
|
@@ -3092,7 +3171,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
3092
3171
|
const seed = type === "retention-schedule-item" ? {
|
|
3093
3172
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
3094
3173
|
...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
|
|
3095
|
-
...(params.
|
|
3174
|
+
...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
|
|
3096
3175
|
...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
|
|
3097
3176
|
} : type === "document" ? {
|
|
3098
3177
|
...(params.get("title") ? { title: params.get("title") } : {}),
|
|
@@ -3155,12 +3234,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3155
3234
|
const narrative = recordNarrative(entry.record, fields);
|
|
3156
3235
|
const narrativeNames = new Set(narrative.map(([name]) => name));
|
|
3157
3236
|
const visible = Object.entries(entry.record).filter(([name]) => (
|
|
3158
|
-
!["id", "type", "title"].includes(name)
|
|
3237
|
+
!["id", "type", "title", "extensions"].includes(name)
|
|
3159
3238
|
&& !fields[name]?.content
|
|
3160
3239
|
&& !narrativeNames.has(name)
|
|
3161
3240
|
));
|
|
3162
3241
|
const content = Object.entries(entry.content);
|
|
3163
|
-
const sourceMetadata = '<div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>';
|
|
3164
3242
|
const narrativeContent = narrative.length
|
|
3165
3243
|
? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
|
|
3166
3244
|
: "";
|
|
@@ -3213,26 +3291,41 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3213
3291
|
? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
|
|
3214
3292
|
: "";
|
|
3215
3293
|
const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
|
|
3216
|
-
const
|
|
3217
|
-
? '<section class="panel detail-
|
|
3294
|
+
const recordFieldsPanel = visible.length
|
|
3295
|
+
? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>'
|
|
3218
3296
|
: "";
|
|
3219
|
-
const participationPanel = entry.historyLoaded === false
|
|
3220
|
-
? '<section class="panel detail-support-panel" role="status" aria-live="polite" aria-busy="true"><div class="panel-head"><h3>Participation</h3></div><p class="muted is-loading">' + loadingIndicator("Loading participation and file history…") + '</p></section>'
|
|
3221
|
-
: entry.historyError
|
|
3222
|
-
? '<section class="panel detail-support-panel" role="status" aria-live="polite"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Participation and file history are unavailable. Reload the record to try again.</p></section>'
|
|
3223
|
-
: personParticipation(entry);
|
|
3224
3297
|
const supportPanels = renderDetailSupport({
|
|
3225
3298
|
hasRecordBody,
|
|
3226
3299
|
workflowPanel: workflowGuidance({ type, id, title: "Next steps", workflow: entry.workflow }) || recordCompletionState(entry.record),
|
|
3227
|
-
metadataPanel:
|
|
3228
|
-
attachmentPanel
|
|
3229
|
-
participationPanel,
|
|
3230
|
-
connectionsPanel: resourceConnections(entry),
|
|
3231
|
-
historyPanel
|
|
3300
|
+
metadataPanel: recordFieldsPanel,
|
|
3301
|
+
attachmentPanel
|
|
3232
3302
|
});
|
|
3233
|
-
|
|
3303
|
+
const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton;
|
|
3304
|
+
const recordMenu = '<details class="record-overflow"><summary aria-label="More record actions" title="More record actions"><svg viewBox="0 0 18 4" aria-hidden="true"><circle cx="2" cy="2" r="2"></circle><circle cx="9" cy="2" r="2"></circle><circle cx="16" cy="2" r="2"></circle></svg></summary><div class="record-overflow-menu" aria-label="Record actions"><button type="button" data-record-modal="info">Record info</button><button type="button" data-record-modal="connections">Connections</button><button type="button" data-record-modal="history"' + (entry.historyLoaded === false ? ' disabled' : '') + '>File history' + (entry.historyLoaded === false ? ' (loading)' : '') + '</button>' + (mayDelete ? '<div class="record-overflow-separator"></div><button class="danger-text" type="button" data-delete-resource>Delete record</button>' : '') + '</div></details>';
|
|
3305
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence & Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
|
|
3234
3306
|
main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
|
|
3235
3307
|
main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
|
|
3308
|
+
main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => {
|
|
3309
|
+
button.closest("details")?.removeAttribute("open");
|
|
3310
|
+
openRecordUtilityDialog(entry, button.dataset.recordModal);
|
|
3311
|
+
}));
|
|
3312
|
+
const overflowMenu = main.querySelector(".record-overflow");
|
|
3313
|
+
const stopOutsideDismissal = () => document.removeEventListener("click", dismissOnOutsideClick);
|
|
3314
|
+
const dismissOnOutsideClick = (event) => {
|
|
3315
|
+
if (overflowMenu.contains(event.target)) return;
|
|
3316
|
+
overflowMenu.removeAttribute("open");
|
|
3317
|
+
stopOutsideDismissal();
|
|
3318
|
+
};
|
|
3319
|
+
overflowMenu?.addEventListener("toggle", () => {
|
|
3320
|
+
stopOutsideDismissal();
|
|
3321
|
+
if (overflowMenu.open) queueMicrotask(() => document.addEventListener("click", dismissOnOutsideClick));
|
|
3322
|
+
});
|
|
3323
|
+
overflowMenu?.addEventListener("keydown", (event) => {
|
|
3324
|
+
if (event.key !== "Escape" || !overflowMenu.open) return;
|
|
3325
|
+
overflowMenu.removeAttribute("open");
|
|
3326
|
+
overflowMenu.querySelector("summary")?.focus();
|
|
3327
|
+
});
|
|
3328
|
+
recordOverflowCleanup = stopOutsideDismissal;
|
|
3236
3329
|
main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
|
|
3237
3330
|
main.querySelector("[data-next-audit-cycle]")?.addEventListener("click", () => openNextAuditCycleDialog(entry.record));
|
|
3238
3331
|
main.querySelector("[data-correct-audit-population]")?.addEventListener("click", async () => {
|
|
@@ -3343,7 +3436,8 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3343
3436
|
}));
|
|
3344
3437
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
3345
3438
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
3346
|
-
main.querySelector("
|
|
3439
|
+
main.querySelector("[data-delete-resource]")?.addEventListener("click", async () => {
|
|
3440
|
+
main.querySelector(".record-overflow")?.removeAttribute("open");
|
|
3347
3441
|
const repositoryPrefetch = prefetchRepositoryForReview();
|
|
3348
3442
|
if (!await confirmAction({
|
|
3349
3443
|
kicker: "Delete record",
|
|
@@ -3369,17 +3463,50 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
3369
3463
|
}
|
|
3370
3464
|
}
|
|
3371
3465
|
|
|
3372
|
-
function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel
|
|
3373
|
-
const panels = workflowPanel + metadataPanel + attachmentPanel
|
|
3466
|
+
function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel }) {
|
|
3467
|
+
const panels = workflowPanel + metadataPanel + attachmentPanel;
|
|
3374
3468
|
if (hasRecordBody) return '<aside>' + panels + '</aside>';
|
|
3375
3469
|
const stacks = [
|
|
3376
3470
|
{ name: "guidance", content: workflowPanel },
|
|
3377
|
-
{ name: "record", content: metadataPanel + attachmentPanel
|
|
3378
|
-
{ name: "relationships", content: participationPanel + connectionsPanel }
|
|
3471
|
+
{ name: "record", content: metadataPanel + attachmentPanel }
|
|
3379
3472
|
].filter(({ content }) => content);
|
|
3380
3473
|
return '<aside class="detail-support-columns">' + stacks.map(({ name, content }) => '<div class="detail-support-stack ' + name + '">' + content + '</div>').join("") + '</aside>';
|
|
3381
3474
|
}
|
|
3382
3475
|
|
|
3476
|
+
function openRecordUtilityDialog(entry, view) {
|
|
3477
|
+
const dialog = document.createElement("dialog");
|
|
3478
|
+
dialog.className = "alert-dialog record-utility-dialog";
|
|
3479
|
+
const definitions = {
|
|
3480
|
+
info: {
|
|
3481
|
+
kicker: "Record info",
|
|
3482
|
+
title: entry.record.title,
|
|
3483
|
+
body: '<dl class="metadata record-info"><div><dt>Record ID</dt><dd><code>' + esc(entry.record.id) + '</code></dd></div><div><dt>Type</dt><dd>' + esc(state.model.resources[entry.record.type].title) + '</dd></div><div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>' + (entry.record.extensions ? '<div><dt>Extensions</dt><dd>' + formatValue(entry.record.extensions, "extensions", entry.record.type) + '</dd></div>' : '') + '</dl><details class="record-json"><summary>View raw JSON</summary><pre class="compact-json">' + esc(JSON.stringify(entry.record, null, 2)) + '</pre></details>'
|
|
3484
|
+
},
|
|
3485
|
+
connections: {
|
|
3486
|
+
kicker: "Connections",
|
|
3487
|
+
title: entry.record.title,
|
|
3488
|
+
body: (personParticipation(entry, true) + resourceConnections(entry, true)) || '<p class="muted">This record has no connected records.</p>'
|
|
3489
|
+
},
|
|
3490
|
+
history: {
|
|
3491
|
+
kicker: "File history",
|
|
3492
|
+
title: entry.record.title,
|
|
3493
|
+
body: entry.historyError
|
|
3494
|
+
? '<p class="muted">File history is unavailable. Reload the record to try again.</p>'
|
|
3495
|
+
: entry.history?.length
|
|
3496
|
+
? '<div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div>'
|
|
3497
|
+
: '<p class="muted">No committed history is available for this record.</p>'
|
|
3498
|
+
}
|
|
3499
|
+
};
|
|
3500
|
+
const definition = definitions[view];
|
|
3501
|
+
if (!definition) return;
|
|
3502
|
+
dialog.setAttribute("aria-labelledby", "record-utility-title");
|
|
3503
|
+
dialog.innerHTML = '<div class="dialog-head"><div><p class="kicker">' + esc(definition.kicker) + '</p><h2 id="record-utility-title">' + esc(titleCase(definition.title)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><div class="record-utility-body">' + definition.body + '</div><div class="dialog-actions"><button type="button" class="button primary">Close</button></div>';
|
|
3504
|
+
document.body.append(dialog);
|
|
3505
|
+
dialog.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
3506
|
+
dialog.addEventListener("close", () => dialog.remove(), { once: true });
|
|
3507
|
+
dialog.showModal();
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3383
3510
|
function evidenceAttachmentPanel(entry) {
|
|
3384
3511
|
const attachments = (entry.record.filePaths || []).map((path) => ({
|
|
3385
3512
|
path,
|
|
@@ -3563,7 +3690,7 @@ function recordContentDefinition(type) {
|
|
|
3563
3690
|
};
|
|
3564
3691
|
}
|
|
3565
3692
|
|
|
3566
|
-
function personParticipation(entry) {
|
|
3693
|
+
function personParticipation(entry, expanded = false) {
|
|
3567
3694
|
if (entry.record.type !== "person") return "";
|
|
3568
3695
|
const personId = entry.record.id;
|
|
3569
3696
|
const appointments = resourcesOfType("appointment")
|
|
@@ -3603,8 +3730,8 @@ function personParticipation(entry) {
|
|
|
3603
3730
|
const count = affiliations.length + assignments.length;
|
|
3604
3731
|
if (!count) return "";
|
|
3605
3732
|
return '<section class="panel connections-panel"><div class="panel-head"><h3>Participation</h3><span>' + count + '</span></div>'
|
|
3606
|
-
+ personParticipationGroup("Appointments and teams", affiliations, 8, "more appointments or teams")
|
|
3607
|
-
+ personParticipationGroup("Assigned records", assignments, 10, "more assigned records")
|
|
3733
|
+
+ personParticipationGroup("Appointments and teams", affiliations, expanded ? affiliations.length : 8, "more appointments or teams")
|
|
3734
|
+
+ personParticipationGroup("Assigned records", assignments, expanded ? assignments.length : 10, "more assigned records")
|
|
3608
3735
|
+ '</section>';
|
|
3609
3736
|
}
|
|
3610
3737
|
|
|
@@ -3617,7 +3744,7 @@ function personParticipationGroup(title, rows, limit, moreLabel) {
|
|
|
3617
3744
|
+ '</div>';
|
|
3618
3745
|
}
|
|
3619
3746
|
|
|
3620
|
-
function resourceConnections(entry) {
|
|
3747
|
+
function resourceConnections(entry, expanded = false) {
|
|
3621
3748
|
const relatedPeopleOnly = entry.record.type === "person";
|
|
3622
3749
|
const connections = new Map();
|
|
3623
3750
|
const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
|
|
@@ -3652,7 +3779,7 @@ function resourceConnections(entry) {
|
|
|
3652
3779
|
return firstType - secondType || first.entry.record.title.localeCompare(second.entry.record.title);
|
|
3653
3780
|
});
|
|
3654
3781
|
if (!sorted.length) return "";
|
|
3655
|
-
const visible = sorted.slice(0, 14);
|
|
3782
|
+
const visible = expanded ? sorted : sorted.slice(0, 14);
|
|
3656
3783
|
return '<section class="panel connections-panel"><div class="panel-head"><h3>' + (relatedPeopleOnly ? "Related people" : "Connections") + '</h3><span>' + sorted.length + '</span></div><div class="connections">' + visible.map(({ entry: connected, reasons }) => '<a href="#/resource/' + encodeURIComponent(connected.record.type) + '/' + encodeURIComponent(connected.record.id) + '"><strong>' + esc(connected.record.title) + '</strong><small>' + esc([...reasons].join(" · ")) + '</small></a>').join("") + '</div>' + (sorted.length > visible.length ? '<p class="connections-more">' + (sorted.length - visible.length) + ' more connections are available through the linked records.</p>' : "") + '</section>';
|
|
3657
3784
|
}
|
|
3658
3785
|
|
|
@@ -4655,6 +4782,9 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4655
4782
|
Array.isArray(group) ? { fields: group } : group
|
|
4656
4783
|
));
|
|
4657
4784
|
const oneOf = new Set(oneOfGroups.flatMap((group) => group.fields || []).filter((name) => !name.startsWith("$markdown:")));
|
|
4785
|
+
const hiddenFields = type === "retention-schedule-item" && modelSupports("retention-schedule-approval")
|
|
4786
|
+
? new Set(["approvedByIds", "approvedOn"])
|
|
4787
|
+
: new Set();
|
|
4658
4788
|
const activeOneOf = new Set(oneOfGroups
|
|
4659
4789
|
.filter((group) => !group.when || conditionMatches(record, group.when))
|
|
4660
4790
|
.flatMap((group) => group.fields || []));
|
|
@@ -4668,6 +4798,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4668
4798
|
...oneOf
|
|
4669
4799
|
])].filter((name) => (
|
|
4670
4800
|
!["id", "type"].includes(name)
|
|
4801
|
+
&& !hiddenFields.has(name)
|
|
4671
4802
|
&& fields[name]
|
|
4672
4803
|
&& !(options.occurrenceReview && ["members", "expectedCount", "completedCount", "conclusion"].includes(name))
|
|
4673
4804
|
));
|
|
@@ -6385,7 +6516,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6385
6516
|
.reconciliation-panel>p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidates{display:grid;gap:9px}.reconciliation-candidate{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);gap:16px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.reconciliation-candidate strong{margin-left:8px;font-size:13.2px}.reconciliation-candidate p{margin:7px 0;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidate small{display:block;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px}.reconciliation-actions{display:grid;gap:8px;justify-items:end;align-content:center}.reconciliation-actions>code{max-width:100%;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px;text-align:right}
|
|
6386
6517
|
.onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
|
|
6387
6518
|
.save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
|
|
6388
|
-
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
|
|
6519
|
+
.page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center;justify-content:flex-end;flex-wrap:wrap}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}.record-overflow{position:relative;flex:0 0 auto;margin-left:auto}.record-overflow>summary{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);cursor:pointer;list-style:none}.record-overflow>summary::-webkit-details-marker{display:none}.record-overflow>summary svg{display:block;width:18px;height:4px;fill:currentColor}.record-overflow>summary:hover,.record-overflow[open]>summary{border-color:var(--accent-light);background:var(--accent-soft);color:var(--accent)}.record-overflow-menu{position:absolute;z-index:12;top:calc(100% + 6px);right:0;display:grid;width:190px;padding:6px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:0 14px 36px rgba(0,0,53,.16)}.record-overflow-menu button{width:100%;border:0;border-radius:6px;padding:9px 10px;background:none;color:var(--ink);font-size:13.2px;text-align:left;cursor:pointer}.record-overflow-menu button:hover{background:var(--surface-soft)}.record-overflow-menu button:disabled{cursor:wait;color:var(--muted)}.record-overflow-menu .danger-text{color:var(--red)}.record-overflow-separator{height:1px;margin:5px 4px;background:var(--line)}.record-utility-dialog{width:min(680px,calc(100vw - 30px));max-height:min(760px,calc(100vh - 30px));overflow:auto}.record-utility-body{display:grid;gap:12px;margin:19px 0}.record-utility-body>.panel{box-shadow:none}.record-info>div:first-child{border-top:0}.record-json{border-top:1px solid var(--line);padding-top:13px}.record-json summary{color:var(--accent);font-size:13.2px;font-weight:750;cursor:pointer}.record-json pre{max-height:360px;overflow:auto;margin:12px 0 0}.record-utility-dialog .history{max-height:none}
|
|
6389
6520
|
.not-found{max-width:620px;padding:28px}.not-found h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.not-found>p:not(.kicker){margin:0;color:var(--muted);font-size:15.6px}.not-found .page-actions{justify-content:flex-start;margin-top:22px}
|
|
6390
6521
|
.button.danger-action{background:var(--red);border-color:var(--red);color:#fff}
|
|
6391
6522
|
@media(max-width:1200px){.readiness-flow{grid-template-columns:repeat(3,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr 1fr}.audit-engagement .button{grid-column:1/-1;justify-self:start}}
|
|
@@ -6396,13 +6527,12 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6396
6527
|
.detail-grid aside .panel{width:100%;max-height:min(520px,65vh);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.detail-grid aside .workflow-guidance,.detail-grid aside .resource-review-criteria{margin:0}.detail-grid aside .resource-review-criteria{padding:21px}.detail-grid aside .detail-workflow-panel{max-height:min(440px,60vh)}.detail-grid aside .detail-review-panel{max-height:min(300px,50vh)}.detail-grid aside .workflow-findings,.detail-grid aside .resource-review-criteria ul{grid-template-columns:1fr}.detail-support-stack{display:grid;min-width:0;gap:14px;align-content:start}
|
|
6397
6528
|
@media(max-width:760px){.detail-grid aside{order:-1}}
|
|
6398
6529
|
@media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
|
|
6399
|
-
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}
|
|
6530
|
+
@media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}}
|
|
6400
6531
|
@media(max-width:760px){.guide-review ul,.resource-review-criteria ul{grid-template-columns:1fr}.collection-review-head,.collection-review-foot{align-items:stretch;flex-direction:column}.record-workflow-action{min-width:0}}
|
|
6401
6532
|
@media(max-width:760px){.policy-content-table>.section-head{display:block}.policy-content-tools{justify-content:flex-start;margin-top:15px}.policy-content-tools>label:first-child{flex:1 1 100%;width:100%}.policy-content-tools input[type="search"]{width:100%;min-width:0}}
|
|
6402
6533
|
@media(max-width:520px){.onboarding-form,.onboarding-sections,.setup-steps{grid-template-columns:1fr}.onboarding-form label.wide{grid-column:auto}.onboarding-actions{flex-wrap:wrap}.onboarding-skip{width:100%;order:3;margin:3px 0 0}.readiness-flow{grid-template-columns:1fr}.obligation-card-foot{align-items:flex-start;flex-direction:column}.obligation-action{align-self:flex-start}}
|
|
6403
6534
|
@media(min-width:761px){.detail-grid{grid-template-columns:minmax(270px,1fr) minmax(0,2fr)}.detail-grid aside{grid-column:1;grid-row:1}.detail-main{grid-column:2;grid-row:1}}
|
|
6404
6535
|
@media(min-width:761px){.detail-grid.detail-grid-structured{grid-template-columns:1fr}.detail-grid-structured aside{grid-column:1;grid-row:1}.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
6405
|
-
@media(min-width:1300px){.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
|
6406
6536
|
@media(max-width:760px){.sidebar{visibility:hidden;transition:transform .2s,visibility 0s .2s}.sidebar.shown{visibility:visible;transition-delay:0s}.nav-close{display:grid;place-items:center;position:absolute;top:25px;right:18px;width:34px;height:34px;border:1px solid #5966a4;border-radius:50%;background:#11174a;color:#eef1ff;font-size:24px;cursor:pointer}.nav-scrim{display:block;position:fixed;inset:0;border:0;background:rgba(0,0,24,.38);opacity:0;pointer-events:none;transition:opacity .2s;z-index:15}.sidebar.shown+.nav-scrim{opacity:1;pointer-events:auto}.pagination{justify-content:space-between;gap:8px}.page-status{min-width:0}}
|
|
6407
6537
|
@media(max-width:760px){.topbar{height:56px}.topbar>div:first-of-type{display:none}.topbar-readiness{flex:0 1 220px;min-width:120px}.nav-close{font-size:0}.nav-close:before,.nav-close:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.nav-close:after{transform:rotate(-45deg)}}
|
|
6408
6538
|
|