filegrc 0.12.4 → 0.13.1
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/package.json +1 -1
- package/src/files.js +13 -5
- package/src/git.js +122 -25
- package/src/program-amendment.js +3 -2
- package/src/program-path.js +4 -4
- package/src/program-readiness.js +26 -7
- package/src/reconciliation.js +10 -5
- package/src/reporting-route-integrity.js +181 -0
- package/src/reporting-route-sets.js +157 -25
- package/src/server.js +154 -68
- package/src/state.js +30 -5
- package/src/validate.js +136 -14
- package/src/web.js +218 -22
package/src/web.js
CHANGED
|
@@ -94,6 +94,7 @@ let repositorySyncPollTimer = null;
|
|
|
94
94
|
let repositorySyncPollInFlight = false;
|
|
95
95
|
let mutationStateRefreshInFlight = false;
|
|
96
96
|
let mutationStateRefreshTimer = null;
|
|
97
|
+
let mutationStateRefreshRequested = false;
|
|
97
98
|
let programSelectionGeneration = 0;
|
|
98
99
|
let expiredStateRefresh = null;
|
|
99
100
|
|
|
@@ -132,7 +133,8 @@ function render() {
|
|
|
132
133
|
if (nextNavigation) nextNavigation.scrollTop = navigationScrollTop;
|
|
133
134
|
const main = root.querySelector("main");
|
|
134
135
|
const waitingFor = blockingStateSections(route).filter((section) => state.sections?.[section] !== "complete");
|
|
135
|
-
if (
|
|
136
|
+
if (state.refreshingAfterMutation) renderStateLoading(main, route, []);
|
|
137
|
+
else if (waitingFor.length) renderStateLoading(main, route, waitingFor);
|
|
136
138
|
else if (route.name === "home") renderHome(main);
|
|
137
139
|
else if (route.name === "stage") renderStageOverview(main, route.stageId, route.params);
|
|
138
140
|
else if (route.name === "obligations") renderObligations(main, route.params);
|
|
@@ -174,7 +176,7 @@ function desiredStateSections(route) {
|
|
|
174
176
|
const sections = new Set(["repository", ...blockingStateSections(route)]);
|
|
175
177
|
if (route.name === "list" && (["requirement", "requirement-mapping"].includes(route.type) || state.model.collectionReviews?.[route.type])) sections.add("program");
|
|
176
178
|
if (route.name === "detail" && ["policy", "document", "training", "control", "component", "requirement-mapping", "retention-schedule-item"].includes(route.type)) sections.add("program");
|
|
177
|
-
if (route.name === "detail"
|
|
179
|
+
if (route.name === "detail") sections.add("workflow");
|
|
178
180
|
if (route.name === "detail" && ["obligation", "action-item", "obligation-event"].includes(route.type)) sections.add("obligations");
|
|
179
181
|
if (route.name === "detail" && route.type === "audit") sections.add("audits");
|
|
180
182
|
return [...sections];
|
|
@@ -660,6 +662,7 @@ function openDocumentActivationDialog(auditId = null) {
|
|
|
660
662
|
'<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
|
|
661
663
|
'<div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Activate selected content</button></div></form>';
|
|
662
664
|
document.body.append(dialog);
|
|
665
|
+
const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
|
|
663
666
|
const close = () => dialog.close();
|
|
664
667
|
dialog.querySelector(".icon-button").addEventListener("click", close);
|
|
665
668
|
dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
|
|
@@ -674,6 +677,8 @@ function openDocumentActivationDialog(auditId = null) {
|
|
|
674
677
|
}
|
|
675
678
|
setMutationBusy(dialog, true, "Activating…", "Activate selected content");
|
|
676
679
|
try {
|
|
680
|
+
const prefetch = await repositoryPrefetch;
|
|
681
|
+
if (prefetch?.error) throw prefetch.error;
|
|
677
682
|
const response = await localFetch(auditId ? "/api/document-activations" : "/api/governed-content-activations", {
|
|
678
683
|
method: "POST",
|
|
679
684
|
headers: { "content-type": "application/json" },
|
|
@@ -683,6 +688,7 @@ function openDocumentActivationDialog(auditId = null) {
|
|
|
683
688
|
activatedByIds: [event.currentTarget.elements.activatedById.value],
|
|
684
689
|
activatedOn: event.currentTarget.elements.activatedOn.value,
|
|
685
690
|
effectiveOn: event.currentTarget.elements.effectiveOn.value,
|
|
691
|
+
prefetchToken: prefetch?.token,
|
|
686
692
|
expectedRevisions: Object.fromEntries(resourceIds.map((resourceId) => [resourceId, entryById.get(resourceId).revision]))
|
|
687
693
|
})
|
|
688
694
|
});
|
|
@@ -759,6 +765,7 @@ function openPolicyActivationDialog() {
|
|
|
759
765
|
'<label><span>Effective date</span><input name="effectiveOn" type="date" min="' + esc(today) + '" value="' + esc(today) + '" required></label>' +
|
|
760
766
|
'<div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button id="activate-policy" type="submit" class="button primary">Activate selected Policies</button></div></form>';
|
|
761
767
|
document.body.append(dialog);
|
|
768
|
+
const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
|
|
762
769
|
const close = () => dialog.close();
|
|
763
770
|
dialog.querySelector(".icon-button").addEventListener("click", close);
|
|
764
771
|
dialog.querySelector('[data-event="cancel"]').addEventListener("click", close);
|
|
@@ -773,12 +780,15 @@ function openPolicyActivationDialog() {
|
|
|
773
780
|
}
|
|
774
781
|
setMutationBusy(dialog, true, "Activating…", "Activate selected Policies");
|
|
775
782
|
try {
|
|
783
|
+
const prefetch = await repositoryPrefetch;
|
|
784
|
+
if (prefetch?.error) throw prefetch.error;
|
|
776
785
|
const response = await localFetch("/api/policy-activations", {
|
|
777
786
|
method: "POST",
|
|
778
787
|
headers: { "content-type": "application/json" },
|
|
779
788
|
body: JSON.stringify({
|
|
780
789
|
policyIds,
|
|
781
790
|
effectiveOn: event.currentTarget.elements.effectiveOn.value,
|
|
791
|
+
prefetchToken: prefetch?.token,
|
|
782
792
|
expectedRevisions: Object.fromEntries(policyIds.map((policyId) => [policyId, entryById.get(policyId).revision]))
|
|
783
793
|
})
|
|
784
794
|
});
|
|
@@ -1001,10 +1011,12 @@ function workflowItemHref(item) {
|
|
|
1001
1011
|
if (source && ["assigned-work", "obligation-occurrence"].includes(item.kind)) {
|
|
1002
1012
|
return "#/stage/run?work=" + encodeURIComponent(source.type + ":" + source.id);
|
|
1003
1013
|
}
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1014
|
+
const actions = [...(item.actions || []), item.nextAction].filter(Boolean);
|
|
1015
|
+
const reconciliationAction = actions.find((action) => action.kind === "reconcile-transition");
|
|
1016
|
+
if (reconciliationAction?.candidateId) {
|
|
1017
|
+
return "#/stage/run?reconcile=" + encodeURIComponent(reconciliationAction.candidateId);
|
|
1018
|
+
}
|
|
1019
|
+
const commands = actions.map((action) => action.command).filter(Boolean);
|
|
1008
1020
|
if (item.createResourceType && state.model.resources[item.createResourceType]) {
|
|
1009
1021
|
const params = new URLSearchParams({ new: "1" });
|
|
1010
1022
|
if (item.title) params.set("title", item.createResourceType === "commitment"
|
|
@@ -1612,6 +1624,16 @@ function renderObligations(main, params = new URLSearchParams()) {
|
|
|
1612
1624
|
if (requestedEvent) main.querySelector('[data-start-event="' + CSS.escape(requestedEvent) + '"]')?.click();
|
|
1613
1625
|
});
|
|
1614
1626
|
}
|
|
1627
|
+
const requestedReconciliation = params.get("reconcile");
|
|
1628
|
+
if (requestedReconciliation) {
|
|
1629
|
+
queueMicrotask(() => {
|
|
1630
|
+
const candidate = state.reconciliation?.candidates?.find((item) => (
|
|
1631
|
+
item.transitionFingerprint === requestedReconciliation
|
|
1632
|
+
|| item.id === requestedReconciliation
|
|
1633
|
+
));
|
|
1634
|
+
if (candidate) openReconciliationConfirmation(candidate);
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1615
1637
|
const requestedWork = params.get("work");
|
|
1616
1638
|
if (requestedWork) {
|
|
1617
1639
|
queueMicrotask(() => {
|
|
@@ -2674,10 +2696,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2674
2696
|
const definition = state.model.resources[type];
|
|
2675
2697
|
if (!entry || !definition) return renderNotFound(main);
|
|
2676
2698
|
if (entry.detailsLoaded === false) {
|
|
2677
|
-
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record
|
|
2699
|
+
main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="#/resources/' + encodeURIComponent(type) + '">' + esc(titleCase(definition.pluralTitle)) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div></div><section class="panel detail-loading" role="status">Loading record…</section></div>';
|
|
2678
2700
|
loadResourceDetail(type, id);
|
|
2679
2701
|
return;
|
|
2680
2702
|
}
|
|
2703
|
+
if (entry.historyLoaded === false) loadResourceHistory(type, id);
|
|
2681
2704
|
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
2682
2705
|
const recordContent = recordContentDefinition(type);
|
|
2683
2706
|
const narrative = recordNarrative(entry.record, fields);
|
|
@@ -2744,13 +2767,16 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2744
2767
|
const historyPanel = entry.history?.length
|
|
2745
2768
|
? '<section class="panel detail-history-panel"><div class="panel-head"><h3>File History</h3></div><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></section>'
|
|
2746
2769
|
: "";
|
|
2770
|
+
const participationPanel = entry.historyLoaded === false
|
|
2771
|
+
? '<section class="panel detail-support-panel" role="status"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Loading participation and file history…</p></section>'
|
|
2772
|
+
: personParticipation(entry);
|
|
2747
2773
|
const supportPanels = renderDetailSupport({
|
|
2748
2774
|
hasRecordBody,
|
|
2749
2775
|
workflowPanel: workflowGuidance({ type, id, title: "Next steps" }),
|
|
2750
2776
|
reviewPanel: resourceReviewCriteria(type),
|
|
2751
2777
|
metadataPanel: '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + sourceMetadata + 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>',
|
|
2752
2778
|
attachmentPanel,
|
|
2753
|
-
participationPanel
|
|
2779
|
+
participationPanel,
|
|
2754
2780
|
connectionsPanel: resourceConnections(entry),
|
|
2755
2781
|
historyPanel
|
|
2756
2782
|
});
|
|
@@ -2867,6 +2893,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2867
2893
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
2868
2894
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
2869
2895
|
main.querySelector("#delete-resource")?.addEventListener("click", async () => {
|
|
2896
|
+
const repositoryPrefetch = prefetchRepositoryForReview();
|
|
2870
2897
|
if (!await confirmAction({
|
|
2871
2898
|
kicker: "Delete record",
|
|
2872
2899
|
title: entry.record.title,
|
|
@@ -2875,7 +2902,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2875
2902
|
danger: true
|
|
2876
2903
|
})) return;
|
|
2877
2904
|
try {
|
|
2878
|
-
const
|
|
2905
|
+
const prefetch = await repositoryPrefetch;
|
|
2906
|
+
if (prefetch?.error) throw prefetch.error;
|
|
2907
|
+
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision) + (prefetch?.token ? "&prefetchToken=" + encodeURIComponent(prefetch.token) : ""), { method: "DELETE" });
|
|
2879
2908
|
if (!response.ok) return showError(await responseMessage(response));
|
|
2880
2909
|
applyMutationState(await response.json());
|
|
2881
2910
|
location.hash = "#/resources/" + encodeURIComponent(type);
|
|
@@ -2925,7 +2954,7 @@ async function loadResourceDetail(type, id) {
|
|
|
2925
2954
|
let token = state.stateToken;
|
|
2926
2955
|
let detail = null;
|
|
2927
2956
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2928
|
-
const tokenQuery = token ? "?token=" + encodeURIComponent(token) : "";
|
|
2957
|
+
const tokenQuery = token ? "?token=" + encodeURIComponent(token) + "&history=false" : "?history=false";
|
|
2929
2958
|
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
|
|
2930
2959
|
if (response.status === 409 && token) {
|
|
2931
2960
|
await refreshExpiredAppState(token);
|
|
@@ -2963,6 +2992,44 @@ async function loadResourceDetail(type, id) {
|
|
|
2963
2992
|
return request;
|
|
2964
2993
|
}
|
|
2965
2994
|
|
|
2995
|
+
async function loadResourceHistory(type, id) {
|
|
2996
|
+
const key = (state.stateToken || "live") + "\0history\0" + type + "\0" + id;
|
|
2997
|
+
if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
|
|
2998
|
+
const request = (async () => {
|
|
2999
|
+
try {
|
|
3000
|
+
const token = state.stateToken;
|
|
3001
|
+
const tokenQuery = (token ? "?token=" + encodeURIComponent(token) + "&" : "?") + "history=only";
|
|
3002
|
+
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
|
|
3003
|
+
if (response.status === 409 && token) {
|
|
3004
|
+
await refreshExpiredAppState(token);
|
|
3005
|
+
const route = parseRoute();
|
|
3006
|
+
if (route.name === "detail" && route.type === type && route.id === id) {
|
|
3007
|
+
render();
|
|
3008
|
+
loadStateForRoute();
|
|
3009
|
+
}
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
3013
|
+
const history = await response.json();
|
|
3014
|
+
if (token && (history.stateToken !== token || state.stateToken !== token)) return;
|
|
3015
|
+
const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
|
|
3016
|
+
if (!entry) return;
|
|
3017
|
+
entry.history = history.history || [];
|
|
3018
|
+
entry.historyLoaded = true;
|
|
3019
|
+
const route = parseRoute();
|
|
3020
|
+
if (route.name === "detail" && route.type === type && route.id === id) render();
|
|
3021
|
+
} catch {
|
|
3022
|
+
// History and participation are supplemental. The record stays usable.
|
|
3023
|
+
} finally {
|
|
3024
|
+
for (const [requestKey, pending] of resourceDetailRequests) {
|
|
3025
|
+
if (pending === request) resourceDetailRequests.delete(requestKey);
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
})();
|
|
3029
|
+
resourceDetailRequests.set(key, request);
|
|
3030
|
+
return request;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
2966
3033
|
function refreshExpiredAppState(expectedToken) {
|
|
2967
3034
|
if (expiredStateRefresh) {
|
|
2968
3035
|
if (expiredStateRefresh.token === expectedToken) return expiredStateRefresh.promise;
|
|
@@ -3274,6 +3341,67 @@ function openReconciliationDismissal(candidate) {
|
|
|
3274
3341
|
});
|
|
3275
3342
|
}
|
|
3276
3343
|
|
|
3344
|
+
function openReconciliationConfirmation(candidate) {
|
|
3345
|
+
if (document.querySelector('[data-reconciliation-confirmation="' + CSS.escape(candidate.transitionFingerprint) + '"]')) return;
|
|
3346
|
+
const writeDisabled = state.readOnly
|
|
3347
|
+
? ' disabled title="Writes are not available in this repository state"'
|
|
3348
|
+
: "";
|
|
3349
|
+
const needsTimestamp = (candidate.requiredFacts || []).includes("occurredAt");
|
|
3350
|
+
const eventField = needsTimestamp
|
|
3351
|
+
? '<label><span>Event time <small>your local time</small></span><input name="occurredAt" type="datetime-local" required value="' + esc(currentLocalDateTime()) + '"></label>'
|
|
3352
|
+
: '<label><span>Event date</span><input name="occurredOn" type="date" required value="' + esc(currentDate()) + '"></label>';
|
|
3353
|
+
const riskField = (candidate.requiredFacts || []).includes("riskLevel")
|
|
3354
|
+
? '<label><span>Departure risk</span><select name="riskLevel" required><option value="normal">Normal</option><option value="high">High or involuntary</option></select></label>'
|
|
3355
|
+
: "";
|
|
3356
|
+
const dialog = document.createElement("dialog");
|
|
3357
|
+
dialog.className = "commit-dialog event-dialog";
|
|
3358
|
+
dialog.dataset.reconciliationConfirmation = candidate.transitionFingerprint;
|
|
3359
|
+
dialog.setAttribute("aria-labelledby", "reconciliation-confirmation-title");
|
|
3360
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git transition review</p><h2 id="reconciliation-confirmation-title">' + esc(policyEventName(candidate.eventType)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(candidate.message) + '</p><section class="event-dialog-steps"><div><strong>' + esc(candidate.subject.title || candidate.subject.id) + '</strong><small>' + esc(candidate.sourcePath) + '</small></div></section>' + eventField + riskField + '<label><span>Workflow name <small>optional</small></span><input name="title" maxlength="200" placeholder="' + esc(policyEventName(candidate.eventType)) + '"' + writeDisabled + '></label>' + (state.readOnly ? '<p class="dialog-note">Open this workspace in the local writable renderer or use the CLI to confirm or dismiss this transition.</p>' : "") + '<div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-dismiss-candidate' + writeDisabled + '>Dismiss false positive</button><button type="button" class="button" data-dismiss-dialog>Cancel</button><button type="submit" class="button primary"' + writeDisabled + '>Confirm and add work</button></div></form>';
|
|
3361
|
+
document.body.append(dialog);
|
|
3362
|
+
dialog.showModal();
|
|
3363
|
+
dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
|
|
3364
|
+
dialog.querySelector("[data-dismiss-dialog]").addEventListener("click", () => dialog.close());
|
|
3365
|
+
dialog.querySelector("[data-dismiss-candidate]").addEventListener("click", () => {
|
|
3366
|
+
dialog.close();
|
|
3367
|
+
openReconciliationDismissal(candidate);
|
|
3368
|
+
});
|
|
3369
|
+
dialog.addEventListener("close", () => dialog.remove());
|
|
3370
|
+
dialog.querySelector("form").addEventListener("submit", async (event) => {
|
|
3371
|
+
event.preventDefault();
|
|
3372
|
+
const form = event.currentTarget;
|
|
3373
|
+
if (!form.reportValidity()) return;
|
|
3374
|
+
try {
|
|
3375
|
+
setMutationBusy(dialog, true, "Confirming…", "Confirm and add work");
|
|
3376
|
+
const response = await localFetch("/api/reconciliation", {
|
|
3377
|
+
method: "POST",
|
|
3378
|
+
headers: { "content-type": "application/json" },
|
|
3379
|
+
body: JSON.stringify({
|
|
3380
|
+
candidateId: candidate.transitionFingerprint,
|
|
3381
|
+
occurredOn: form.elements.occurredOn?.value || undefined,
|
|
3382
|
+
occurredAt: form.elements.occurredAt?.value ? new Date(form.elements.occurredAt.value).toISOString() : undefined,
|
|
3383
|
+
riskLevel: form.elements.riskLevel?.value || undefined,
|
|
3384
|
+
title: form.elements.title.value,
|
|
3385
|
+
confirmed: true
|
|
3386
|
+
})
|
|
3387
|
+
});
|
|
3388
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
3389
|
+
const created = await response.json();
|
|
3390
|
+
policyEventFeedback = {
|
|
3391
|
+
name: policyEventName(candidate.eventType),
|
|
3392
|
+
taskCount: created.actions?.length || 0
|
|
3393
|
+
};
|
|
3394
|
+
applyMutationState(created);
|
|
3395
|
+
dialog.close();
|
|
3396
|
+
history.replaceState(null, "", "#/stage/run");
|
|
3397
|
+
render();
|
|
3398
|
+
} catch (error) {
|
|
3399
|
+
setMutationBusy(dialog, false, "", "Confirm and add work");
|
|
3400
|
+
dialog.querySelector(".dialog-error").textContent = error.message;
|
|
3401
|
+
}
|
|
3402
|
+
});
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3277
3405
|
async function runRepositoryGitAction(action) {
|
|
3278
3406
|
const buttons = [...document.querySelectorAll("[data-git-action]")];
|
|
3279
3407
|
const disabled = buttons.map((button) => button.disabled);
|
|
@@ -4109,6 +4237,14 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4109
4237
|
'<details class="advanced-editor"><summary>Advanced JSON</summary><p>Use this for optional fields, extensions, or bulk edits. Changes here replace the guided fields above.</p><textarea spellcheck="false" aria-label="Advanced resource JSON">' + esc(JSON.stringify(record, null, 2)) + '</textarea></details><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button type="button" class="button" data-editor-dismiss>Cancel</button><button type="submit" class="button primary" id="save-record">' + esc(options.saveLabel || "Save file") + '</button></div></form>';
|
|
4110
4238
|
document.body.append(dialog);
|
|
4111
4239
|
dialog.showModal();
|
|
4240
|
+
const saveStatus = dialog.querySelector(".save-status");
|
|
4241
|
+
const fastResourceSave = !options.occurrenceReconciliation
|
|
4242
|
+
&& !options.auditPopulationCorrection
|
|
4243
|
+
&& !options.actionCompletion
|
|
4244
|
+
&& !options.obligationCompletion;
|
|
4245
|
+
const repositoryPrefetch = fastResourceSave
|
|
4246
|
+
? prefetchRepositoryForReview(saveStatus)
|
|
4247
|
+
: Promise.resolve(null);
|
|
4112
4248
|
dialog.addEventListener("close", () => dialog.remove());
|
|
4113
4249
|
dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
4114
4250
|
wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
|
|
@@ -4181,9 +4317,14 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4181
4317
|
[recordContentItem?.path, recordContentItem?.revision]
|
|
4182
4318
|
].filter(([path, revision]) => path && revision));
|
|
4183
4319
|
setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
|
|
4320
|
+
const prefetch = await repositoryPrefetch;
|
|
4321
|
+
if (prefetch?.error) throw prefetch.error;
|
|
4184
4322
|
const response = await localFetch(url, {
|
|
4185
4323
|
method: options.occurrenceReconciliation ? "POST" : entry ? "PUT" : "POST",
|
|
4186
|
-
headers: {
|
|
4324
|
+
headers: {
|
|
4325
|
+
"content-type": "application/json",
|
|
4326
|
+
...(fastResourceSave ? { prefer: "respond-async" } : {})
|
|
4327
|
+
},
|
|
4187
4328
|
body: JSON.stringify({
|
|
4188
4329
|
record: updated,
|
|
4189
4330
|
content,
|
|
@@ -4191,7 +4332,8 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4191
4332
|
contentRevisions,
|
|
4192
4333
|
obligationId: options.obligationCompletion?.obligationId,
|
|
4193
4334
|
actionItemId: options.actionCompletion?.actionItemId,
|
|
4194
|
-
completedOn: options.actionCompletion?.completedOn
|
|
4335
|
+
completedOn: options.actionCompletion?.completedOn,
|
|
4336
|
+
prefetchToken: prefetch?.token
|
|
4195
4337
|
})
|
|
4196
4338
|
});
|
|
4197
4339
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
@@ -4952,12 +5094,15 @@ function openContentEditor(entry, name) {
|
|
|
4952
5094
|
dialog.innerHTML = '<form method="dialog"><div class="dialog-head"><div><p class="kicker">Edit Markdown</p><h2 id="content-editor-title">' + esc(titleCase(entry.record.title)) + '</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><p><code>' + esc(item.path) + '</code></p><textarea class="markdown-source" spellcheck="true" aria-label="Markdown content">' + esc(item.source) + '</textarea><div class="dialog-error" role="alert"></div><div class="save-status" role="status" aria-live="polite"></div><div class="dialog-actions"><button value="cancel" class="button">Cancel</button><button type="button" class="button primary" id="save-content">Save Markdown</button></div></form>';
|
|
4953
5095
|
document.body.append(dialog);
|
|
4954
5096
|
dialog.showModal();
|
|
5097
|
+
const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
|
|
4955
5098
|
dialog.addEventListener("close", () => dialog.remove());
|
|
4956
5099
|
dialog.querySelector("#save-content").addEventListener("click", async () => {
|
|
4957
5100
|
if (dialog.dataset.mutationBusy === "true") return;
|
|
4958
5101
|
try {
|
|
4959
5102
|
setMutationBusy(dialog, true, "Saving…", "Save Markdown");
|
|
4960
|
-
const
|
|
5103
|
+
const prefetch = await repositoryPrefetch;
|
|
5104
|
+
if (prefetch?.error) throw prefetch.error;
|
|
5105
|
+
const response = await localFetch("/api/content", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: item.path, source: dialog.querySelector(".markdown-source").value, revision: item.revision, prefetchToken: prefetch?.token }) });
|
|
4961
5106
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
4962
5107
|
applyMutationState(await response.json());
|
|
4963
5108
|
dialog.close();
|
|
@@ -5430,7 +5575,16 @@ function applyMutationState(result) {
|
|
|
5430
5575
|
state = normalizeAppState(result.state);
|
|
5431
5576
|
} else if (result?.stateRefresh) {
|
|
5432
5577
|
applyFastMutationPatch(result);
|
|
5578
|
+
state.refreshingAfterMutation = true;
|
|
5579
|
+
state.readOnly = true;
|
|
5580
|
+
state.resources = state.resources.map((entry) => ({
|
|
5581
|
+
...entry,
|
|
5582
|
+
content: {},
|
|
5583
|
+
history: undefined,
|
|
5584
|
+
detailsLoaded: false
|
|
5585
|
+
}));
|
|
5433
5586
|
scheduleMutationStateRefresh();
|
|
5587
|
+
render();
|
|
5434
5588
|
} else {
|
|
5435
5589
|
throw new Error("The save response did not include the current workspace state.");
|
|
5436
5590
|
}
|
|
@@ -5439,6 +5593,19 @@ function applyMutationState(result) {
|
|
|
5439
5593
|
|
|
5440
5594
|
function applyFastMutationPatch(result) {
|
|
5441
5595
|
state.stateToken = null;
|
|
5596
|
+
if (result.deleted && result.type && result.id) {
|
|
5597
|
+
state.resources = state.resources.filter(({ record }) => record.type !== result.type || record.id !== result.id);
|
|
5598
|
+
}
|
|
5599
|
+
if (result.dataRelativePath && typeof result.source === "string") {
|
|
5600
|
+
for (const entry of state.resources) {
|
|
5601
|
+
for (const item of Object.values(entry.content || {})) {
|
|
5602
|
+
if (item.path !== result.dataRelativePath) continue;
|
|
5603
|
+
item.source = result.source;
|
|
5604
|
+
item.revision = result.revision;
|
|
5605
|
+
if (typeof result.html === "string") item.html = result.html;
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
}
|
|
5442
5609
|
if (result.operation === "collection-review" && result.assessment?.resourceType) {
|
|
5443
5610
|
state.collectionReviews[result.assessment.resourceType] = result.assessment;
|
|
5444
5611
|
}
|
|
@@ -5451,7 +5618,24 @@ function applyFastMutationPatch(result) {
|
|
|
5451
5618
|
state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
|
|
5452
5619
|
}
|
|
5453
5620
|
}
|
|
5454
|
-
|
|
5621
|
+
const immediateRecords = [
|
|
5622
|
+
result.record,
|
|
5623
|
+
result.workspace,
|
|
5624
|
+
result.program,
|
|
5625
|
+
result.system,
|
|
5626
|
+
result.renderer,
|
|
5627
|
+
result.commitment,
|
|
5628
|
+
result.audit,
|
|
5629
|
+
result.event,
|
|
5630
|
+
result.created,
|
|
5631
|
+
result.linked,
|
|
5632
|
+
result.dismissal,
|
|
5633
|
+
result.result?.record,
|
|
5634
|
+
result.result?.created,
|
|
5635
|
+
result.result?.linked,
|
|
5636
|
+
...(result.actions || [])
|
|
5637
|
+
].filter((record) => record?.id && record?.type);
|
|
5638
|
+
for (const record of immediateRecords) {
|
|
5455
5639
|
const entry = state.resources.find(({ record: current }) => current.id === record.id);
|
|
5456
5640
|
if (entry) entry.record = record;
|
|
5457
5641
|
else state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
|
|
@@ -5471,6 +5655,7 @@ function applyFastMutationPatch(result) {
|
|
|
5471
5655
|
}
|
|
5472
5656
|
|
|
5473
5657
|
function scheduleMutationStateRefresh(delay = 0) {
|
|
5658
|
+
mutationStateRefreshRequested = true;
|
|
5474
5659
|
if (mutationStateRefreshTimer || mutationStateRefreshInFlight) return;
|
|
5475
5660
|
mutationStateRefreshTimer = setTimeout(refreshMutationState, delay);
|
|
5476
5661
|
}
|
|
@@ -5479,6 +5664,7 @@ async function refreshMutationState() {
|
|
|
5479
5664
|
mutationStateRefreshTimer = null;
|
|
5480
5665
|
if (mutationStateRefreshInFlight) return;
|
|
5481
5666
|
mutationStateRefreshInFlight = true;
|
|
5667
|
+
mutationStateRefreshRequested = false;
|
|
5482
5668
|
let retry = false;
|
|
5483
5669
|
try {
|
|
5484
5670
|
const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
|
|
@@ -5494,24 +5680,24 @@ async function refreshMutationState() {
|
|
|
5494
5680
|
} finally {
|
|
5495
5681
|
mutationStateRefreshInFlight = false;
|
|
5496
5682
|
}
|
|
5497
|
-
if (retry) scheduleMutationStateRefresh(1_000);
|
|
5683
|
+
if (retry || mutationStateRefreshRequested) scheduleMutationStateRefresh(retry ? 1_000 : 0);
|
|
5498
5684
|
}
|
|
5499
5685
|
|
|
5500
|
-
function prefetchRepositoryForReview(status) {
|
|
5686
|
+
function prefetchRepositoryForReview(status = null) {
|
|
5501
5687
|
if (state.repository?.mode !== "trunk" || state.repository?.developmentOverride) {
|
|
5502
5688
|
return Promise.resolve(null);
|
|
5503
5689
|
}
|
|
5504
|
-
status.textContent = "Checking repository…";
|
|
5690
|
+
if (status) status.textContent = "Checking repository…";
|
|
5505
5691
|
return fetch("/api/git/prefetch", { method: "POST" })
|
|
5506
5692
|
.then(async (response) => {
|
|
5507
5693
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5508
5694
|
const result = await response.json();
|
|
5509
|
-
status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
|
|
5695
|
+
if (status) status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
|
|
5510
5696
|
return result;
|
|
5511
5697
|
})
|
|
5512
5698
|
.catch((error) => {
|
|
5513
|
-
status.textContent = "Repository check failed";
|
|
5514
|
-
return
|
|
5699
|
+
if (status) status.textContent = "Repository check failed";
|
|
5700
|
+
return null;
|
|
5515
5701
|
});
|
|
5516
5702
|
}
|
|
5517
5703
|
|
|
@@ -5624,15 +5810,25 @@ async function localFetch(url, options) {
|
|
|
5624
5810
|
}
|
|
5625
5811
|
const scopedUrl = requestUrl.pathname + requestUrl.search + requestUrl.hash;
|
|
5626
5812
|
const method = String(options?.method || "GET").toUpperCase();
|
|
5813
|
+
const mutation = ["POST", "PUT", "DELETE"].includes(method);
|
|
5814
|
+
const requestOptions = mutation
|
|
5815
|
+
? {
|
|
5816
|
+
...options,
|
|
5817
|
+
headers: {
|
|
5818
|
+
...Object.fromEntries(new Headers(options?.headers || {}).entries()),
|
|
5819
|
+
prefer: "respond-async"
|
|
5820
|
+
}
|
|
5821
|
+
}
|
|
5822
|
+
: options;
|
|
5627
5823
|
const synchronizing = state?.repository?.mode === "trunk"
|
|
5628
|
-
&&
|
|
5824
|
+
&& mutation
|
|
5629
5825
|
&& !["/api/evidence-packet", "/api/git/prefetch"].includes(requestUrl.pathname);
|
|
5630
5826
|
const chip = synchronizing ? document.querySelector(".repo-chip") : null;
|
|
5631
5827
|
const previousChip = chip?.innerHTML;
|
|
5632
5828
|
let repositoryRefreshed = false;
|
|
5633
5829
|
if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
|
|
5634
5830
|
try {
|
|
5635
|
-
const response = await fetch(scopedUrl,
|
|
5831
|
+
const response = await fetch(scopedUrl, requestOptions);
|
|
5636
5832
|
if (synchronizing && !response.ok) {
|
|
5637
5833
|
try {
|
|
5638
5834
|
const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(state.selectedProgramId || ""));
|