filegrc 0.13.0 → 0.13.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/package.json +4 -2
- package/src/collection-review.js +28 -8
- package/src/files.js +13 -5
- package/src/git.js +2032 -759
- package/src/server.js +154 -68
- package/src/state.js +30 -5
- package/src/validate.js +51 -13
- package/src/web.js +154 -20
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);
|
|
@@ -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
|
});
|
|
@@ -928,15 +938,26 @@ function openCollectionReviewDialog(type) {
|
|
|
928
938
|
const sourceType = v4 ? "component" : "system";
|
|
929
939
|
const systems = resourcesOfType(sourceType).filter(({ record }) => record.status === "active");
|
|
930
940
|
const allowedDecisions = configuration.decisions || ["complete"];
|
|
931
|
-
const
|
|
932
|
-
|
|
941
|
+
const priorDecision = assessment.review?.decision;
|
|
942
|
+
const priorAuthoritativeSourceId = assessment.review?.authoritativeComponentId || assessment.review?.authoritativeSystemId || null;
|
|
943
|
+
const priorAuthoritativeSourceActive = priorDecision !== "externally-managed"
|
|
944
|
+
|| systems.some(({ record }) => record.id === priorAuthoritativeSourceId);
|
|
945
|
+
const priorDecisionStillValid = allowedDecisions.includes(priorDecision)
|
|
946
|
+
&& priorAuthoritativeSourceActive
|
|
947
|
+
&& (assessment.recordCount ? priorDecision !== "zero-population" : priorDecision !== "complete");
|
|
948
|
+
const preservedAuthoritativeSourceId = priorDecisionStillValid && priorDecision === "externally-managed"
|
|
949
|
+
? priorAuthoritativeSourceId
|
|
950
|
+
: null;
|
|
951
|
+
const defaultDecision = priorDecisionStillValid
|
|
952
|
+
? priorDecision
|
|
953
|
+
: !assessment.recordCount && allowedDecisions.includes("zero-population") ? "zero-population" : allowedDecisions[0];
|
|
933
954
|
const decisions = allowedDecisions.map((decision) => (
|
|
934
955
|
'<option value="' + esc(decision) + '" ' + (defaultDecision === decision ? "selected" : "") + '>' + esc(properCase(decision)) + '</option>'
|
|
935
956
|
)).join("");
|
|
936
957
|
const dialog = document.createElement("dialog");
|
|
937
958
|
dialog.className = "commit-dialog event-dialog collection-review-dialog";
|
|
938
959
|
dialog.setAttribute("aria-labelledby", "collection-review-dialog-title");
|
|
939
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(
|
|
960
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Scope confirmation</p><h2 id="collection-review-dialog-title">Confirm ' + esc(configuration.title.toLowerCase()) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>Before confirming</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section><div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (preservedAuthoritativeSourceId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>Confirm ' + assessment.recordCount + ' current ' + esc(pluralize("record", assessment.recordCount)) + '. If the collection or material scope changes, FileGRC will ask for another review.</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button><button type="submit" class="button primary">Confirm and save</button></div></form>';
|
|
940
961
|
document.body.append(dialog);
|
|
941
962
|
dialog.showModal();
|
|
942
963
|
const form = dialog.querySelector("form");
|
|
@@ -2686,10 +2707,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2686
2707
|
const definition = state.model.resources[type];
|
|
2687
2708
|
if (!entry || !definition) return renderNotFound(main);
|
|
2688
2709
|
if (entry.detailsLoaded === false) {
|
|
2689
|
-
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
|
|
2710
|
+
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>';
|
|
2690
2711
|
loadResourceDetail(type, id);
|
|
2691
2712
|
return;
|
|
2692
2713
|
}
|
|
2714
|
+
if (entry.historyLoaded === false) loadResourceHistory(type, id);
|
|
2693
2715
|
const fields = { ...state.model.commonFields, ...definition.fields };
|
|
2694
2716
|
const recordContent = recordContentDefinition(type);
|
|
2695
2717
|
const narrative = recordNarrative(entry.record, fields);
|
|
@@ -2756,13 +2778,16 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2756
2778
|
const historyPanel = entry.history?.length
|
|
2757
2779
|
? '<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>'
|
|
2758
2780
|
: "";
|
|
2781
|
+
const participationPanel = entry.historyLoaded === false
|
|
2782
|
+
? '<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>'
|
|
2783
|
+
: personParticipation(entry);
|
|
2759
2784
|
const supportPanels = renderDetailSupport({
|
|
2760
2785
|
hasRecordBody,
|
|
2761
2786
|
workflowPanel: workflowGuidance({ type, id, title: "Next steps" }),
|
|
2762
2787
|
reviewPanel: resourceReviewCriteria(type),
|
|
2763
2788
|
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>',
|
|
2764
2789
|
attachmentPanel,
|
|
2765
|
-
participationPanel
|
|
2790
|
+
participationPanel,
|
|
2766
2791
|
connectionsPanel: resourceConnections(entry),
|
|
2767
2792
|
historyPanel
|
|
2768
2793
|
});
|
|
@@ -2879,6 +2904,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2879
2904
|
main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
|
|
2880
2905
|
main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
|
|
2881
2906
|
main.querySelector("#delete-resource")?.addEventListener("click", async () => {
|
|
2907
|
+
const repositoryPrefetch = prefetchRepositoryForReview();
|
|
2882
2908
|
if (!await confirmAction({
|
|
2883
2909
|
kicker: "Delete record",
|
|
2884
2910
|
title: entry.record.title,
|
|
@@ -2887,7 +2913,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
|
|
|
2887
2913
|
danger: true
|
|
2888
2914
|
})) return;
|
|
2889
2915
|
try {
|
|
2890
|
-
const
|
|
2916
|
+
const prefetch = await repositoryPrefetch;
|
|
2917
|
+
if (prefetch?.error) throw prefetch.error;
|
|
2918
|
+
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + "?revision=" + encodeURIComponent(entry.revision) + (prefetch?.token ? "&prefetchToken=" + encodeURIComponent(prefetch.token) : ""), { method: "DELETE" });
|
|
2891
2919
|
if (!response.ok) return showError(await responseMessage(response));
|
|
2892
2920
|
applyMutationState(await response.json());
|
|
2893
2921
|
location.hash = "#/resources/" + encodeURIComponent(type);
|
|
@@ -2937,7 +2965,7 @@ async function loadResourceDetail(type, id) {
|
|
|
2937
2965
|
let token = state.stateToken;
|
|
2938
2966
|
let detail = null;
|
|
2939
2967
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2940
|
-
const tokenQuery = token ? "?token=" + encodeURIComponent(token) : "";
|
|
2968
|
+
const tokenQuery = token ? "?token=" + encodeURIComponent(token) + "&history=false" : "?history=false";
|
|
2941
2969
|
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
|
|
2942
2970
|
if (response.status === 409 && token) {
|
|
2943
2971
|
await refreshExpiredAppState(token);
|
|
@@ -2975,6 +3003,44 @@ async function loadResourceDetail(type, id) {
|
|
|
2975
3003
|
return request;
|
|
2976
3004
|
}
|
|
2977
3005
|
|
|
3006
|
+
async function loadResourceHistory(type, id) {
|
|
3007
|
+
const key = (state.stateToken || "live") + "\0history\0" + type + "\0" + id;
|
|
3008
|
+
if (resourceDetailRequests.has(key)) return resourceDetailRequests.get(key);
|
|
3009
|
+
const request = (async () => {
|
|
3010
|
+
try {
|
|
3011
|
+
const token = state.stateToken;
|
|
3012
|
+
const tokenQuery = (token ? "?token=" + encodeURIComponent(token) + "&" : "?") + "history=only";
|
|
3013
|
+
const response = await localFetch("/api/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(id) + tokenQuery);
|
|
3014
|
+
if (response.status === 409 && token) {
|
|
3015
|
+
await refreshExpiredAppState(token);
|
|
3016
|
+
const route = parseRoute();
|
|
3017
|
+
if (route.name === "detail" && route.type === type && route.id === id) {
|
|
3018
|
+
render();
|
|
3019
|
+
loadStateForRoute();
|
|
3020
|
+
}
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if (!response.ok) throw new Error(await responseMessage(response));
|
|
3024
|
+
const history = await response.json();
|
|
3025
|
+
if (token && (history.stateToken !== token || state.stateToken !== token)) return;
|
|
3026
|
+
const entry = state.resources.find(({ record }) => record.type === type && record.id === id);
|
|
3027
|
+
if (!entry) return;
|
|
3028
|
+
entry.history = history.history || [];
|
|
3029
|
+
entry.historyLoaded = true;
|
|
3030
|
+
const route = parseRoute();
|
|
3031
|
+
if (route.name === "detail" && route.type === type && route.id === id) render();
|
|
3032
|
+
} catch {
|
|
3033
|
+
// History and participation are supplemental. The record stays usable.
|
|
3034
|
+
} finally {
|
|
3035
|
+
for (const [requestKey, pending] of resourceDetailRequests) {
|
|
3036
|
+
if (pending === request) resourceDetailRequests.delete(requestKey);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
})();
|
|
3040
|
+
resourceDetailRequests.set(key, request);
|
|
3041
|
+
return request;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
2978
3044
|
function refreshExpiredAppState(expectedToken) {
|
|
2979
3045
|
if (expiredStateRefresh) {
|
|
2980
3046
|
if (expiredStateRefresh.token === expectedToken) return expiredStateRefresh.promise;
|
|
@@ -4182,6 +4248,14 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4182
4248
|
'<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>';
|
|
4183
4249
|
document.body.append(dialog);
|
|
4184
4250
|
dialog.showModal();
|
|
4251
|
+
const saveStatus = dialog.querySelector(".save-status");
|
|
4252
|
+
const fastResourceSave = !options.occurrenceReconciliation
|
|
4253
|
+
&& !options.auditPopulationCorrection
|
|
4254
|
+
&& !options.actionCompletion
|
|
4255
|
+
&& !options.obligationCompletion;
|
|
4256
|
+
const repositoryPrefetch = fastResourceSave
|
|
4257
|
+
? prefetchRepositoryForReview(saveStatus)
|
|
4258
|
+
: Promise.resolve(null);
|
|
4185
4259
|
dialog.addEventListener("close", () => dialog.remove());
|
|
4186
4260
|
dialog.querySelectorAll("[data-editor-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close()));
|
|
4187
4261
|
wireEditorRequirements(dialog, record, fields, oneOfGroups, markdownDefinitions);
|
|
@@ -4254,9 +4328,14 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4254
4328
|
[recordContentItem?.path, recordContentItem?.revision]
|
|
4255
4329
|
].filter(([path, revision]) => path && revision));
|
|
4256
4330
|
setMutationBusy(dialog, true, "Saving…", options.saveLabel || "Save file");
|
|
4331
|
+
const prefetch = await repositoryPrefetch;
|
|
4332
|
+
if (prefetch?.error) throw prefetch.error;
|
|
4257
4333
|
const response = await localFetch(url, {
|
|
4258
4334
|
method: options.occurrenceReconciliation ? "POST" : entry ? "PUT" : "POST",
|
|
4259
|
-
headers: {
|
|
4335
|
+
headers: {
|
|
4336
|
+
"content-type": "application/json",
|
|
4337
|
+
...(fastResourceSave ? { prefer: "respond-async" } : {})
|
|
4338
|
+
},
|
|
4260
4339
|
body: JSON.stringify({
|
|
4261
4340
|
record: updated,
|
|
4262
4341
|
content,
|
|
@@ -4264,7 +4343,8 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4264
4343
|
contentRevisions,
|
|
4265
4344
|
obligationId: options.obligationCompletion?.obligationId,
|
|
4266
4345
|
actionItemId: options.actionCompletion?.actionItemId,
|
|
4267
|
-
completedOn: options.actionCompletion?.completedOn
|
|
4346
|
+
completedOn: options.actionCompletion?.completedOn,
|
|
4347
|
+
prefetchToken: prefetch?.token
|
|
4268
4348
|
})
|
|
4269
4349
|
});
|
|
4270
4350
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
@@ -5025,12 +5105,15 @@ function openContentEditor(entry, name) {
|
|
|
5025
5105
|
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>';
|
|
5026
5106
|
document.body.append(dialog);
|
|
5027
5107
|
dialog.showModal();
|
|
5108
|
+
const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status"));
|
|
5028
5109
|
dialog.addEventListener("close", () => dialog.remove());
|
|
5029
5110
|
dialog.querySelector("#save-content").addEventListener("click", async () => {
|
|
5030
5111
|
if (dialog.dataset.mutationBusy === "true") return;
|
|
5031
5112
|
try {
|
|
5032
5113
|
setMutationBusy(dialog, true, "Saving…", "Save Markdown");
|
|
5033
|
-
const
|
|
5114
|
+
const prefetch = await repositoryPrefetch;
|
|
5115
|
+
if (prefetch?.error) throw prefetch.error;
|
|
5116
|
+
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 }) });
|
|
5034
5117
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5035
5118
|
applyMutationState(await response.json());
|
|
5036
5119
|
dialog.close();
|
|
@@ -5503,7 +5586,16 @@ function applyMutationState(result) {
|
|
|
5503
5586
|
state = normalizeAppState(result.state);
|
|
5504
5587
|
} else if (result?.stateRefresh) {
|
|
5505
5588
|
applyFastMutationPatch(result);
|
|
5589
|
+
state.refreshingAfterMutation = true;
|
|
5590
|
+
state.readOnly = true;
|
|
5591
|
+
state.resources = state.resources.map((entry) => ({
|
|
5592
|
+
...entry,
|
|
5593
|
+
content: {},
|
|
5594
|
+
history: undefined,
|
|
5595
|
+
detailsLoaded: false
|
|
5596
|
+
}));
|
|
5506
5597
|
scheduleMutationStateRefresh();
|
|
5598
|
+
render();
|
|
5507
5599
|
} else {
|
|
5508
5600
|
throw new Error("The save response did not include the current workspace state.");
|
|
5509
5601
|
}
|
|
@@ -5512,6 +5604,19 @@ function applyMutationState(result) {
|
|
|
5512
5604
|
|
|
5513
5605
|
function applyFastMutationPatch(result) {
|
|
5514
5606
|
state.stateToken = null;
|
|
5607
|
+
if (result.deleted && result.type && result.id) {
|
|
5608
|
+
state.resources = state.resources.filter(({ record }) => record.type !== result.type || record.id !== result.id);
|
|
5609
|
+
}
|
|
5610
|
+
if (result.dataRelativePath && typeof result.source === "string") {
|
|
5611
|
+
for (const entry of state.resources) {
|
|
5612
|
+
for (const item of Object.values(entry.content || {})) {
|
|
5613
|
+
if (item.path !== result.dataRelativePath) continue;
|
|
5614
|
+
item.source = result.source;
|
|
5615
|
+
item.revision = result.revision;
|
|
5616
|
+
if (typeof result.html === "string") item.html = result.html;
|
|
5617
|
+
}
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5515
5620
|
if (result.operation === "collection-review" && result.assessment?.resourceType) {
|
|
5516
5621
|
state.collectionReviews[result.assessment.resourceType] = result.assessment;
|
|
5517
5622
|
}
|
|
@@ -5524,7 +5629,24 @@ function applyFastMutationPatch(result) {
|
|
|
5524
5629
|
state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
|
|
5525
5630
|
}
|
|
5526
5631
|
}
|
|
5527
|
-
|
|
5632
|
+
const immediateRecords = [
|
|
5633
|
+
result.record,
|
|
5634
|
+
result.workspace,
|
|
5635
|
+
result.program,
|
|
5636
|
+
result.system,
|
|
5637
|
+
result.renderer,
|
|
5638
|
+
result.commitment,
|
|
5639
|
+
result.audit,
|
|
5640
|
+
result.event,
|
|
5641
|
+
result.created,
|
|
5642
|
+
result.linked,
|
|
5643
|
+
result.dismissal,
|
|
5644
|
+
result.result?.record,
|
|
5645
|
+
result.result?.created,
|
|
5646
|
+
result.result?.linked,
|
|
5647
|
+
...(result.actions || [])
|
|
5648
|
+
].filter((record) => record?.id && record?.type);
|
|
5649
|
+
for (const record of immediateRecords) {
|
|
5528
5650
|
const entry = state.resources.find(({ record: current }) => current.id === record.id);
|
|
5529
5651
|
if (entry) entry.record = record;
|
|
5530
5652
|
else state.resources.push({ record, content: {}, history: [], detailsLoaded: false });
|
|
@@ -5544,6 +5666,7 @@ function applyFastMutationPatch(result) {
|
|
|
5544
5666
|
}
|
|
5545
5667
|
|
|
5546
5668
|
function scheduleMutationStateRefresh(delay = 0) {
|
|
5669
|
+
mutationStateRefreshRequested = true;
|
|
5547
5670
|
if (mutationStateRefreshTimer || mutationStateRefreshInFlight) return;
|
|
5548
5671
|
mutationStateRefreshTimer = setTimeout(refreshMutationState, delay);
|
|
5549
5672
|
}
|
|
@@ -5552,6 +5675,7 @@ async function refreshMutationState() {
|
|
|
5552
5675
|
mutationStateRefreshTimer = null;
|
|
5553
5676
|
if (mutationStateRefreshInFlight) return;
|
|
5554
5677
|
mutationStateRefreshInFlight = true;
|
|
5678
|
+
mutationStateRefreshRequested = false;
|
|
5555
5679
|
let retry = false;
|
|
5556
5680
|
try {
|
|
5557
5681
|
const programQuery = state.selectedProgramId ? "?programId=" + encodeURIComponent(state.selectedProgramId) : "";
|
|
@@ -5567,24 +5691,24 @@ async function refreshMutationState() {
|
|
|
5567
5691
|
} finally {
|
|
5568
5692
|
mutationStateRefreshInFlight = false;
|
|
5569
5693
|
}
|
|
5570
|
-
if (retry) scheduleMutationStateRefresh(1_000);
|
|
5694
|
+
if (retry || mutationStateRefreshRequested) scheduleMutationStateRefresh(retry ? 1_000 : 0);
|
|
5571
5695
|
}
|
|
5572
5696
|
|
|
5573
|
-
function prefetchRepositoryForReview(status) {
|
|
5697
|
+
function prefetchRepositoryForReview(status = null) {
|
|
5574
5698
|
if (state.repository?.mode !== "trunk" || state.repository?.developmentOverride) {
|
|
5575
5699
|
return Promise.resolve(null);
|
|
5576
5700
|
}
|
|
5577
|
-
status.textContent = "Checking repository…";
|
|
5701
|
+
if (status) status.textContent = "Checking repository…";
|
|
5578
5702
|
return fetch("/api/git/prefetch", { method: "POST" })
|
|
5579
5703
|
.then(async (response) => {
|
|
5580
5704
|
if (!response.ok) throw new Error(await responseMessage(response));
|
|
5581
5705
|
const result = await response.json();
|
|
5582
|
-
status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
|
|
5706
|
+
if (status) status.textContent = result.status === "checked" ? "Repository checked" : "Ready to save";
|
|
5583
5707
|
return result;
|
|
5584
5708
|
})
|
|
5585
5709
|
.catch((error) => {
|
|
5586
|
-
status.textContent = "Repository check failed";
|
|
5587
|
-
return
|
|
5710
|
+
if (status) status.textContent = "Repository check failed";
|
|
5711
|
+
return null;
|
|
5588
5712
|
});
|
|
5589
5713
|
}
|
|
5590
5714
|
|
|
@@ -5697,15 +5821,25 @@ async function localFetch(url, options) {
|
|
|
5697
5821
|
}
|
|
5698
5822
|
const scopedUrl = requestUrl.pathname + requestUrl.search + requestUrl.hash;
|
|
5699
5823
|
const method = String(options?.method || "GET").toUpperCase();
|
|
5824
|
+
const mutation = ["POST", "PUT", "DELETE"].includes(method);
|
|
5825
|
+
const requestOptions = mutation
|
|
5826
|
+
? {
|
|
5827
|
+
...options,
|
|
5828
|
+
headers: {
|
|
5829
|
+
...Object.fromEntries(new Headers(options?.headers || {}).entries()),
|
|
5830
|
+
prefer: "respond-async"
|
|
5831
|
+
}
|
|
5832
|
+
}
|
|
5833
|
+
: options;
|
|
5700
5834
|
const synchronizing = state?.repository?.mode === "trunk"
|
|
5701
|
-
&&
|
|
5835
|
+
&& mutation
|
|
5702
5836
|
&& !["/api/evidence-packet", "/api/git/prefetch"].includes(requestUrl.pathname);
|
|
5703
5837
|
const chip = synchronizing ? document.querySelector(".repo-chip") : null;
|
|
5704
5838
|
const previousChip = chip?.innerHTML;
|
|
5705
5839
|
let repositoryRefreshed = false;
|
|
5706
5840
|
if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
|
|
5707
5841
|
try {
|
|
5708
|
-
const response = await fetch(scopedUrl,
|
|
5842
|
+
const response = await fetch(scopedUrl, requestOptions);
|
|
5709
5843
|
if (synchronizing && !response.ok) {
|
|
5710
5844
|
try {
|
|
5711
5845
|
const stateResponse = await fetch("/api/state?programId=" + encodeURIComponent(state.selectedProgramId || ""));
|