changeledger 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/bin/changeledger.mjs +29 -2
- package/package.json +1 -1
- package/src/commands/check.mjs +10 -0
- package/src/commands/register.mjs +9 -1
- package/src/commands/view.mjs +4 -0
- package/src/config-migration.mjs +213 -0
- package/src/viewer/domain.mjs +280 -0
- package/src/viewer/public/api.js +28 -0
- package/src/viewer/public/app.js +529 -29
- package/src/viewer/public/index.html +2 -0
- package/src/viewer/public/styles.css +283 -0
- package/src/viewer/server/router.mjs +40 -14
- package/templates/config.yml +4 -0
- package/templates/contract/core.md +7 -0
- package/templates/contract/release.md +10 -0
package/src/viewer/public/app.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
getConfigMigrationPreview,
|
|
2
3
|
getGitRefs,
|
|
3
|
-
|
|
4
|
+
getProjectConfigStructured,
|
|
4
5
|
getProjects,
|
|
5
6
|
getRepo,
|
|
7
|
+
patchProjectConfigApi,
|
|
8
|
+
postConfigMigrationApply,
|
|
6
9
|
postProjectConfig,
|
|
7
10
|
postProjectPath,
|
|
8
11
|
postProjectRemove,
|
|
@@ -255,11 +258,11 @@ async function moveStatus(id, status, reason) {
|
|
|
255
258
|
const res = await postStatus(state.currentProject, id, status, reason);
|
|
256
259
|
const out = await res.json();
|
|
257
260
|
if (!res.ok) {
|
|
258
|
-
|
|
261
|
+
showToast(out.error || 'status change failed');
|
|
259
262
|
return;
|
|
260
263
|
}
|
|
261
264
|
} catch (e) {
|
|
262
|
-
|
|
265
|
+
showToast(e.message);
|
|
263
266
|
return;
|
|
264
267
|
}
|
|
265
268
|
invalidateCache();
|
|
@@ -485,7 +488,7 @@ export function createDiagramLightbox({ overlay, canvas, closeButton }) {
|
|
|
485
488
|
async function gotoChange(proj, changeId) {
|
|
486
489
|
const match = state.projectsList.find((p) => p.id === proj || p.name === proj);
|
|
487
490
|
if (!match?.alive) {
|
|
488
|
-
|
|
491
|
+
showToast(`Project "${proj}" is not registered or its path is gone.`);
|
|
489
492
|
return;
|
|
490
493
|
}
|
|
491
494
|
if (match.id !== state.currentProject) {
|
|
@@ -666,8 +669,316 @@ export function restoreInitialViewerShell(root = document, getStorage = () => wi
|
|
|
666
669
|
|
|
667
670
|
let managedProject = null;
|
|
668
671
|
let managedConfig = null;
|
|
672
|
+
let configMode = 'form'; // 'form' | 'raw'
|
|
673
|
+
let configDirty = false; // true when form/raw has unsaved edits
|
|
674
|
+
let migrationPreview = null; // null | { summary, changes, yaml } | { already_current }
|
|
669
675
|
|
|
670
|
-
|
|
676
|
+
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
677
|
+
// Confirm dialog — uses native <dialog> for proper focus-trap, ESC and backdrop.
|
|
678
|
+
// _confirmImpl is replaceable in tests (JSDOM lacks showModal).
|
|
679
|
+
let _confirmImpl = null;
|
|
680
|
+
let dialogSequence = 0;
|
|
681
|
+
|
|
682
|
+
export function showToast(message, { type = 'error', duration = 4000 } = {}) {
|
|
683
|
+
const container = document.getElementById('toast-container');
|
|
684
|
+
if (!container) return;
|
|
685
|
+
const el = document.createElement('div');
|
|
686
|
+
el.className = `toast toast-${type}`;
|
|
687
|
+
el.textContent = message;
|
|
688
|
+
el.setAttribute('role', 'alert');
|
|
689
|
+
container.appendChild(el);
|
|
690
|
+
setTimeout(() => el.remove(), duration);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function setConfirmImpl(impl) {
|
|
694
|
+
_confirmImpl = impl;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Prompt dialog — returns the entered string or null (cancel). Mockable via _promptImpl.
|
|
698
|
+
let _promptImpl = null;
|
|
699
|
+
export function setPromptImpl(impl) {
|
|
700
|
+
_promptImpl = impl;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export function showPrompt(message, { placeholder = '' } = {}) {
|
|
704
|
+
if (_promptImpl !== null) return Promise.resolve(_promptImpl(message));
|
|
705
|
+
return new Promise((resolve) => {
|
|
706
|
+
const id = `cl-prompt-${++dialogSequence}`;
|
|
707
|
+
const dialog = document.createElement('dialog');
|
|
708
|
+
dialog.className = 'cl-confirm-dialog';
|
|
709
|
+
dialog.setAttribute('aria-labelledby', `${id}-title`);
|
|
710
|
+
dialog.innerHTML = `
|
|
711
|
+
<p id="${id}-title" class="cl-confirm-message"></p>
|
|
712
|
+
<label for="${id}-input" class="cl-prompt-label">Confirmation value</label>
|
|
713
|
+
<input id="${id}-input" class="cl-prompt-input" type="text" autocomplete="off" />
|
|
714
|
+
<div class="cl-confirm-actions">
|
|
715
|
+
<button type="button" class="button cl-confirm-yes">Confirm</button>
|
|
716
|
+
<button type="button" class="button secondary cl-confirm-no">Cancel</button>
|
|
717
|
+
</div>`;
|
|
718
|
+
dialog.querySelector('.cl-confirm-message').textContent = message;
|
|
719
|
+
const input = dialog.querySelector('.cl-prompt-input');
|
|
720
|
+
if (placeholder) input.placeholder = placeholder;
|
|
721
|
+
document.body.appendChild(dialog);
|
|
722
|
+
const done = (result) => {
|
|
723
|
+
dialog.close();
|
|
724
|
+
dialog.remove();
|
|
725
|
+
resolve(result);
|
|
726
|
+
};
|
|
727
|
+
dialog.querySelector('.cl-confirm-yes').onclick = () => done(input.value);
|
|
728
|
+
dialog.querySelector('.cl-confirm-no').onclick = () => done(null);
|
|
729
|
+
dialog.addEventListener('cancel', () => done(null));
|
|
730
|
+
dialog.addEventListener('click', (e) => {
|
|
731
|
+
if (e.target === dialog) done(null);
|
|
732
|
+
});
|
|
733
|
+
input.addEventListener('keydown', (e) => {
|
|
734
|
+
if (e.key === 'Enter') done(input.value);
|
|
735
|
+
});
|
|
736
|
+
dialog.showModal();
|
|
737
|
+
input.focus();
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export function showConfirm(message) {
|
|
742
|
+
if (_confirmImpl) return Promise.resolve(_confirmImpl(message));
|
|
743
|
+
return new Promise((resolve) => {
|
|
744
|
+
const id = `cl-confirm-${++dialogSequence}`;
|
|
745
|
+
const dialog = document.createElement('dialog');
|
|
746
|
+
dialog.className = 'cl-confirm-dialog';
|
|
747
|
+
dialog.setAttribute('aria-labelledby', `${id}-title`);
|
|
748
|
+
dialog.innerHTML = `
|
|
749
|
+
<p id="${id}-title" class="cl-confirm-message"></p>
|
|
750
|
+
<div class="cl-confirm-actions">
|
|
751
|
+
<button type="button" class="button cl-confirm-yes">Confirm</button>
|
|
752
|
+
<button type="button" class="button secondary cl-confirm-no">Cancel</button>
|
|
753
|
+
</div>`;
|
|
754
|
+
dialog.querySelector('.cl-confirm-message').textContent = message;
|
|
755
|
+
document.body.appendChild(dialog);
|
|
756
|
+
const done = (result) => {
|
|
757
|
+
dialog.close();
|
|
758
|
+
dialog.remove();
|
|
759
|
+
resolve(result);
|
|
760
|
+
};
|
|
761
|
+
dialog.querySelector('.cl-confirm-yes').onclick = () => done(true);
|
|
762
|
+
dialog.querySelector('.cl-confirm-no').onclick = () => done(false);
|
|
763
|
+
dialog.addEventListener('cancel', () => done(false));
|
|
764
|
+
dialog.addEventListener('click', (e) => {
|
|
765
|
+
if (e.target === dialog) done(false);
|
|
766
|
+
});
|
|
767
|
+
dialog.showModal();
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function configSectionTemplate(config, mode, preview) {
|
|
772
|
+
if (!config) return nothing;
|
|
773
|
+
if (config.error) {
|
|
774
|
+
return html`<div class="config-section">
|
|
775
|
+
<p class="project-error" role="alert" aria-live="assertive">${config.error}</p>
|
|
776
|
+
</div>`;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const schema = config.schemaVersion ?? 0;
|
|
780
|
+
const futureSch = schema > SUPPORTED_SCHEMA_VERSION;
|
|
781
|
+
const outdated = schema < SUPPORTED_SCHEMA_VERSION;
|
|
782
|
+
|
|
783
|
+
return html`<div class="config-section">
|
|
784
|
+
${
|
|
785
|
+
!futureSch
|
|
786
|
+
? html`<div class="config-tabs" role="tablist" aria-label="Config editor mode">
|
|
787
|
+
<button
|
|
788
|
+
type="button"
|
|
789
|
+
role="tab"
|
|
790
|
+
class=${`config-tab${mode === 'form' ? ' active' : ''}`}
|
|
791
|
+
aria-selected=${mode === 'form'}
|
|
792
|
+
data-config-mode="form"
|
|
793
|
+
>Form</button>
|
|
794
|
+
<button
|
|
795
|
+
type="button"
|
|
796
|
+
role="tab"
|
|
797
|
+
class=${`config-tab${mode === 'raw' ? ' active' : ''}`}
|
|
798
|
+
aria-selected=${mode === 'raw'}
|
|
799
|
+
data-config-mode="raw"
|
|
800
|
+
>Raw YAML</button>
|
|
801
|
+
</div>`
|
|
802
|
+
: nothing
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
${
|
|
806
|
+
futureSch
|
|
807
|
+
? html`<div class="config-future-schema">
|
|
808
|
+
<p class="config-schema-badge">Schema ${schema}</p>
|
|
809
|
+
<p>Update ChangeLedger to edit config schema ${schema}.</p>
|
|
810
|
+
</div>
|
|
811
|
+
${rawReadonlyTemplate(config)}`
|
|
812
|
+
: outdated
|
|
813
|
+
? html`<div class="config-migration-card">
|
|
814
|
+
<h3>Migration required</h3>
|
|
815
|
+
<p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${SUPPORTED_SCHEMA_VERSION} to enable the Form editor.</p>
|
|
816
|
+
${
|
|
817
|
+
preview?.already_current
|
|
818
|
+
? html`<p class="config-migration-ok">Migration already applied.</p>`
|
|
819
|
+
: preview?.error
|
|
820
|
+
? html`<p class="project-error" role="alert" aria-live="assertive">${preview.error}</p>
|
|
821
|
+
<div class="project-actions">
|
|
822
|
+
<button class="button secondary" type="button" data-preview-migration>Retry preview</button>
|
|
823
|
+
</div>`
|
|
824
|
+
: preview
|
|
825
|
+
? html`<div class="config-migration-preview">
|
|
826
|
+
<p class="config-migration-summary">${preview.summary}</p>
|
|
827
|
+
<p><strong>Changes:</strong></p>
|
|
828
|
+
<ul>${preview.changes?.map((c) => html`<li>${c}</li>`)}</ul>
|
|
829
|
+
<pre class="config-migration-yaml">${preview.yaml}</pre>
|
|
830
|
+
<div class="project-actions">
|
|
831
|
+
<button class="button" type="button" data-apply-migration>Apply migration</button>
|
|
832
|
+
</div>
|
|
833
|
+
</div>`
|
|
834
|
+
: html`<div class="project-actions">
|
|
835
|
+
<button class="button secondary" type="button" data-preview-migration>Preview migration</button>
|
|
836
|
+
</div>`
|
|
837
|
+
}
|
|
838
|
+
<p class="config-section-note">You can still inspect the current config in Raw YAML.</p>
|
|
839
|
+
${rawEditorTemplate(config)}
|
|
840
|
+
</div>`
|
|
841
|
+
: mode === 'form'
|
|
842
|
+
? formEditorTemplate(config)
|
|
843
|
+
: rawEditorTemplate(config)
|
|
844
|
+
}
|
|
845
|
+
</div>`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Raw editor with Save button (for editable schemas)
|
|
849
|
+
function rawEditorTemplate(config) {
|
|
850
|
+
return html`<form class="config-form">
|
|
851
|
+
<div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
|
|
852
|
+
<textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
|
|
853
|
+
<p class="project-error" role="alert" aria-live="assertive" ?hidden=${!config?.rawError}>${config?.rawError ?? ''}</p>
|
|
854
|
+
<div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
|
|
855
|
+
</form>`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Raw viewer without Save (future schema — strictly read-only)
|
|
859
|
+
function rawReadonlyTemplate(config) {
|
|
860
|
+
return html`<div class="config-form config-form-readonly">
|
|
861
|
+
<div class="config-label"><label for="project-config-ro">.changeledger/config.yml</label></div>
|
|
862
|
+
<textarea id="project-config-ro" spellcheck="false" readonly .value=${config?.content ?? ''}></textarea>
|
|
863
|
+
<p class="config-note">Editing disabled for schema ${config.schemaVersion ?? '?'}. Update ChangeLedger to enable editing.</p>
|
|
864
|
+
</div>`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function formEditorTemplate(config) {
|
|
868
|
+
const cfg = config.config ?? {};
|
|
869
|
+
const types = cfg.types ?? {};
|
|
870
|
+
const impacts = cfg.release?.impacts ?? {};
|
|
871
|
+
const readiness = cfg.readiness ?? {};
|
|
872
|
+
const allStatuses = cfg.statuses ?? [];
|
|
873
|
+
const stages = cfg.stages ?? [];
|
|
874
|
+
|
|
875
|
+
return html`<form class="config-form config-form-structured" data-config-form>
|
|
876
|
+
<div class="config-label">
|
|
877
|
+
<label>.changeledger/config.yml (Form)</label>
|
|
878
|
+
<button type="button" class="text-button" data-reload-config>Reload</button>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
<fieldset class="config-group">
|
|
882
|
+
<legend>General</legend>
|
|
883
|
+
<label>Project name
|
|
884
|
+
<input name="project_name" .value=${cfg.project_name ?? ''} />
|
|
885
|
+
</label>
|
|
886
|
+
<label>Language
|
|
887
|
+
<input name="language" .value=${cfg.language ?? 'en'} />
|
|
888
|
+
</label>
|
|
889
|
+
<label class="config-checkbox">
|
|
890
|
+
<input type="checkbox" name="tdd" ?checked=${cfg.tdd !== false} />
|
|
891
|
+
TDD mode (require test-grade criteria)
|
|
892
|
+
</label>
|
|
893
|
+
</fieldset>
|
|
894
|
+
|
|
895
|
+
<fieldset class="config-group">
|
|
896
|
+
<legend>Paths</legend>
|
|
897
|
+
<p class="config-note">Changing paths only updates the config — existing files are not moved.</p>
|
|
898
|
+
<label>Changes directory
|
|
899
|
+
<input name="changes_dir" .value=${cfg.changes_dir ?? '.changeledger/changes'} />
|
|
900
|
+
</label>
|
|
901
|
+
<label>Specs directory
|
|
902
|
+
<input name="specs_dir" .value=${cfg.specs_dir ?? '.changeledger/specs'} />
|
|
903
|
+
</label>
|
|
904
|
+
</fieldset>
|
|
905
|
+
|
|
906
|
+
<fieldset class="config-group">
|
|
907
|
+
<legend>Lifecycle statuses</legend>
|
|
908
|
+
<label>Status order (one per line)
|
|
909
|
+
<textarea name="statuses" rows="8">${allStatuses.join('\n')}</textarea>
|
|
910
|
+
</label>
|
|
911
|
+
<p class="config-note">Canonical statuses are required. Custom statuses may be added and reordered.</p>
|
|
912
|
+
</fieldset>
|
|
913
|
+
|
|
914
|
+
<fieldset class="config-group">
|
|
915
|
+
<legend>Lifecycle stages</legend>
|
|
916
|
+
<label>Canonical stage order (one per line)
|
|
917
|
+
<textarea name="stages" rows="6">${stages.join('\n')}</textarea>
|
|
918
|
+
</label>
|
|
919
|
+
<p class="config-note">Stages used by a change type cannot be removed until that type is updated.</p>
|
|
920
|
+
</fieldset>
|
|
921
|
+
|
|
922
|
+
<fieldset class="config-group">
|
|
923
|
+
<legend>Change types</legend>
|
|
924
|
+
${Object.entries(types).map(
|
|
925
|
+
([typeName, typeDef]) => html`
|
|
926
|
+
<fieldset class="config-type-row">
|
|
927
|
+
<legend>${typeName}</legend>
|
|
928
|
+
<label>Active stages (one per line)
|
|
929
|
+
<textarea name=${`stages_${typeName}`} rows="3">${(typeDef?.stages ?? []).join('\n')}</textarea>
|
|
930
|
+
</label>
|
|
931
|
+
<label>Review policy
|
|
932
|
+
<select name=${`review_required_${typeName}`}>
|
|
933
|
+
<option value="" ?selected=${!Object.hasOwn(typeDef ?? {}, 'review_required')}>Not configured</option>
|
|
934
|
+
<option value="true" ?selected=${typeDef?.review_required === true}>Required</option>
|
|
935
|
+
<option value="false" ?selected=${typeDef?.review_required === false}>Not required</option>
|
|
936
|
+
</select>
|
|
937
|
+
</label>
|
|
938
|
+
<label>SemVer impact
|
|
939
|
+
<select name=${`impact_${typeName}`}>
|
|
940
|
+
<option value="" ?selected=${!Object.hasOwn(impacts, typeName)}>Not configured</option>
|
|
941
|
+
${['none', 'patch', 'minor', 'major'].map(
|
|
942
|
+
(v) =>
|
|
943
|
+
html`<option value=${v} ?selected=${impacts[typeName] === v}>${v}</option>`,
|
|
944
|
+
)}
|
|
945
|
+
</select>
|
|
946
|
+
</label>
|
|
947
|
+
</fieldset>
|
|
948
|
+
`,
|
|
949
|
+
)}
|
|
950
|
+
</fieldset>
|
|
951
|
+
|
|
952
|
+
<fieldset class="config-group">
|
|
953
|
+
<legend>Definition of Ready</legend>
|
|
954
|
+
<label>Target patterns (one per line)
|
|
955
|
+
<textarea name="target_patterns" rows="3">${(readiness.target_patterns ?? []).join('\n')}</textarea>
|
|
956
|
+
</label>
|
|
957
|
+
<label>Verification patterns (one per line)
|
|
958
|
+
<textarea name="verification_patterns" rows="3">${(readiness.verification_patterns ?? []).join('\n')}</textarea>
|
|
959
|
+
</label>
|
|
960
|
+
</fieldset>
|
|
961
|
+
|
|
962
|
+
<fieldset class="config-group config-group-internal">
|
|
963
|
+
<legend>Internal</legend>
|
|
964
|
+
<p><span class="config-readonly-label">schema_version</span><span class="config-readonly-value">${cfg.schema_version ?? 0}</span></p>
|
|
965
|
+
<p><span class="config-readonly-label">project_id</span><span class="config-readonly-value mono">${cfg.project_id ?? ''}</span></p>
|
|
966
|
+
</fieldset>
|
|
967
|
+
|
|
968
|
+
<p class="project-error" role="alert" aria-live="assertive" ?hidden=${!config?.formError}>${config?.formError ?? ''}</p>
|
|
969
|
+
<div class="project-actions">
|
|
970
|
+
<button class="button" type="submit">Save configuration</button>
|
|
971
|
+
</div>
|
|
972
|
+
</form>`;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function projectsViewTemplate(
|
|
976
|
+
projects,
|
|
977
|
+
selected,
|
|
978
|
+
config,
|
|
979
|
+
localOnly,
|
|
980
|
+
preview = migrationPreview,
|
|
981
|
+
) {
|
|
671
982
|
const project = projects.find((item) => item.id === selected);
|
|
672
983
|
return html`<div class="projects-shell">
|
|
673
984
|
<div class="projects-list">
|
|
@@ -710,12 +1021,7 @@ export function projectsViewTemplate(projects, selected, config, localOnly) {
|
|
|
710
1021
|
project.alive
|
|
711
1022
|
? config?.loading
|
|
712
1023
|
? html`<p class="empty">Loading configuration…</p>`
|
|
713
|
-
:
|
|
714
|
-
<div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
|
|
715
|
-
<textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
|
|
716
|
-
<p class="project-error" ?hidden=${!config?.error}>${config?.error ?? ''}</p>
|
|
717
|
-
<div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
|
|
718
|
-
</form>`
|
|
1024
|
+
: configSectionTemplate(config, configMode, preview)
|
|
719
1025
|
: html`<div class="missing-config"><h3>Configuration unavailable</h3><p>Repair the registered path to edit this project.</p></div>`
|
|
720
1026
|
}
|
|
721
1027
|
${
|
|
@@ -730,9 +1036,11 @@ export function projectsViewTemplate(projects, selected, config, localOnly) {
|
|
|
730
1036
|
|
|
731
1037
|
async function openManagedProject(id, { reload = false } = {}) {
|
|
732
1038
|
managedProject = id;
|
|
1039
|
+
configDirty = false;
|
|
733
1040
|
const project = state.projectsList.find((item) => item.id === id);
|
|
734
1041
|
if (!project?.alive) {
|
|
735
1042
|
managedConfig = null;
|
|
1043
|
+
migrationPreview = null;
|
|
736
1044
|
renderProjects();
|
|
737
1045
|
return;
|
|
738
1046
|
}
|
|
@@ -741,9 +1049,17 @@ async function openManagedProject(id, { reload = false } = {}) {
|
|
|
741
1049
|
return;
|
|
742
1050
|
}
|
|
743
1051
|
managedConfig = { id, loading: true };
|
|
1052
|
+
migrationPreview = null;
|
|
744
1053
|
renderProjects();
|
|
745
1054
|
try {
|
|
746
|
-
|
|
1055
|
+
const structured = await getProjectConfigStructured(id);
|
|
1056
|
+
managedConfig = { id, ...structured };
|
|
1057
|
+
// Default to form for current schema, raw for future schema
|
|
1058
|
+
if (structured.schemaVersion > SUPPORTED_SCHEMA_VERSION) {
|
|
1059
|
+
configMode = 'raw';
|
|
1060
|
+
} else {
|
|
1061
|
+
configMode = 'form';
|
|
1062
|
+
}
|
|
747
1063
|
} catch (error) {
|
|
748
1064
|
managedConfig = { id, content: '', revision: '', error: error.message };
|
|
749
1065
|
}
|
|
@@ -770,15 +1086,20 @@ export async function projectMutation(root, request, onSuccess) {
|
|
|
770
1086
|
if (error) {
|
|
771
1087
|
error.textContent = failure.message;
|
|
772
1088
|
error.hidden = false;
|
|
773
|
-
} else
|
|
1089
|
+
} else showToast(failure.message);
|
|
774
1090
|
} finally {
|
|
775
1091
|
setProjectFormPending(root, false);
|
|
776
1092
|
}
|
|
777
1093
|
}
|
|
778
1094
|
|
|
779
|
-
export function requestUnregisterConfirmation(project, ask =
|
|
780
|
-
|
|
1095
|
+
export function requestUnregisterConfirmation(project, ask = null) {
|
|
1096
|
+
if (ask !== null)
|
|
1097
|
+
return ask(
|
|
1098
|
+
`Type "${project.name}" to unregister this project. No repository files will be deleted.`,
|
|
1099
|
+
);
|
|
1100
|
+
return showPrompt(
|
|
781
1101
|
`Type "${project.name}" to unregister this project. No repository files will be deleted.`,
|
|
1102
|
+
{ placeholder: project.name },
|
|
782
1103
|
);
|
|
783
1104
|
}
|
|
784
1105
|
|
|
@@ -798,6 +1119,92 @@ async function refreshProjectRegistry() {
|
|
|
798
1119
|
select.style.display = projects.length > 1 ? '' : 'none';
|
|
799
1120
|
}
|
|
800
1121
|
|
|
1122
|
+
const listFromControl = (control) =>
|
|
1123
|
+
(control?.value ?? '')
|
|
1124
|
+
.split('\n')
|
|
1125
|
+
.map((value) => value.trim())
|
|
1126
|
+
.filter(Boolean);
|
|
1127
|
+
|
|
1128
|
+
const sameList = (left = [], right = []) =>
|
|
1129
|
+
left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1130
|
+
|
|
1131
|
+
export function collectFormPatch(formEl, currentConfig) {
|
|
1132
|
+
const patch = {};
|
|
1133
|
+
const els = formEl.elements;
|
|
1134
|
+
|
|
1135
|
+
if (els.project_name && els.project_name.value !== (currentConfig.project_name ?? '')) {
|
|
1136
|
+
patch.project_name = els.project_name.value;
|
|
1137
|
+
}
|
|
1138
|
+
if (els.language && els.language.value !== (currentConfig.language ?? 'en')) {
|
|
1139
|
+
patch.language = els.language.value;
|
|
1140
|
+
}
|
|
1141
|
+
if (els.tdd && els.tdd.checked !== (currentConfig.tdd !== false)) patch.tdd = els.tdd.checked;
|
|
1142
|
+
if (
|
|
1143
|
+
els.changes_dir &&
|
|
1144
|
+
els.changes_dir.value !== (currentConfig.changes_dir ?? '.changeledger/changes')
|
|
1145
|
+
) {
|
|
1146
|
+
patch.changes_dir = els.changes_dir.value;
|
|
1147
|
+
}
|
|
1148
|
+
if (els.specs_dir && els.specs_dir.value !== (currentConfig.specs_dir ?? '.changeledger/specs')) {
|
|
1149
|
+
patch.specs_dir = els.specs_dir.value;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const statuses = listFromControl(els.statuses);
|
|
1153
|
+
if (els.statuses && !sameList(statuses, currentConfig.statuses ?? [])) patch.statuses = statuses;
|
|
1154
|
+
const stages = listFromControl(els.stages);
|
|
1155
|
+
if (els.stages && !sameList(stages, currentConfig.stages ?? [])) patch.stages = stages;
|
|
1156
|
+
|
|
1157
|
+
// Type fields are tri-state: an empty select means the key is not configured.
|
|
1158
|
+
const types = currentConfig.types ?? {};
|
|
1159
|
+
const existingImpacts = currentConfig.release?.impacts ?? {};
|
|
1160
|
+
const typePatch = {};
|
|
1161
|
+
const impacts = {};
|
|
1162
|
+
for (const typeName of Object.keys(types)) {
|
|
1163
|
+
const currentType = types[typeName] ?? {};
|
|
1164
|
+
const typeStages = listFromControl(els[`stages_${typeName}`]);
|
|
1165
|
+
if (els[`stages_${typeName}`] && !sameList(typeStages, currentType.stages ?? [])) {
|
|
1166
|
+
typePatch[typeName] = { ...(typePatch[typeName] ?? {}), stages: typeStages };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const rrEl = els[`review_required_${typeName}`];
|
|
1170
|
+
const currentReview = Object.hasOwn(currentType, 'review_required')
|
|
1171
|
+
? String(currentType.review_required)
|
|
1172
|
+
: '';
|
|
1173
|
+
if (rrEl && rrEl.value !== currentReview) {
|
|
1174
|
+
typePatch[typeName] = {
|
|
1175
|
+
...(typePatch[typeName] ?? {}),
|
|
1176
|
+
review_required: rrEl.value === '' ? null : rrEl.value === 'true',
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const impactEl = els[`impact_${typeName}`];
|
|
1181
|
+
const currentImpact = Object.hasOwn(existingImpacts, typeName) ? existingImpacts[typeName] : '';
|
|
1182
|
+
if (impactEl && impactEl.value !== currentImpact) {
|
|
1183
|
+
impacts[typeName] = impactEl.value === '' ? null : impactEl.value;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (Object.keys(typePatch).length) patch.types = typePatch;
|
|
1187
|
+
if (Object.keys(impacts).length) patch.release = { impacts };
|
|
1188
|
+
|
|
1189
|
+
// readiness patterns
|
|
1190
|
+
if (els.target_patterns !== undefined) {
|
|
1191
|
+
const targetPatterns = listFromControl(els.target_patterns);
|
|
1192
|
+
const verifyPatterns = listFromControl(els.verification_patterns);
|
|
1193
|
+
const currentReadiness = currentConfig.readiness ?? {};
|
|
1194
|
+
if (
|
|
1195
|
+
!sameList(targetPatterns, currentReadiness.target_patterns ?? []) ||
|
|
1196
|
+
!sameList(verifyPatterns, currentReadiness.verification_patterns ?? [])
|
|
1197
|
+
) {
|
|
1198
|
+
patch.readiness = {
|
|
1199
|
+
target_patterns: targetPatterns,
|
|
1200
|
+
verification_patterns: verifyPatterns,
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return patch;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
801
1208
|
function renderProjects() {
|
|
802
1209
|
const root = $('#projects');
|
|
803
1210
|
litRender(
|
|
@@ -805,18 +1212,77 @@ function renderProjects() {
|
|
|
805
1212
|
root,
|
|
806
1213
|
);
|
|
807
1214
|
bindProjectViewActions(root, {
|
|
808
|
-
select: (id) =>
|
|
809
|
-
|
|
810
|
-
|
|
1215
|
+
select: async (id) => {
|
|
1216
|
+
if (configDirty) {
|
|
1217
|
+
const ok = await showConfirm('You have unsaved changes. Switch project and discard them?');
|
|
1218
|
+
if (!ok) return;
|
|
1219
|
+
}
|
|
1220
|
+
openManagedProject(id);
|
|
1221
|
+
},
|
|
1222
|
+
reload: async () => {
|
|
1223
|
+
if (configDirty) {
|
|
1224
|
+
const ok = await showConfirm('Reload will discard your unsaved changes. Continue?');
|
|
1225
|
+
if (!ok) return;
|
|
1226
|
+
}
|
|
1227
|
+
openManagedProject(managedProject, { reload: true });
|
|
1228
|
+
},
|
|
1229
|
+
markDirty: () => {
|
|
1230
|
+
configDirty = true;
|
|
1231
|
+
},
|
|
1232
|
+
switchMode: async (mode) => {
|
|
1233
|
+
if (configDirty && mode !== configMode) {
|
|
1234
|
+
const ok = await showConfirm('You have unsaved changes. Switch mode and discard them?');
|
|
1235
|
+
if (!ok) return;
|
|
1236
|
+
}
|
|
1237
|
+
configMode = mode;
|
|
1238
|
+
configDirty = false;
|
|
1239
|
+
renderProjects();
|
|
1240
|
+
},
|
|
1241
|
+
saveRaw: (content, configForm) =>
|
|
811
1242
|
projectMutation(
|
|
812
1243
|
configForm,
|
|
813
1244
|
() => postProjectConfig(managedProject, content, managedConfig.revision),
|
|
814
1245
|
async (body) => {
|
|
815
|
-
|
|
1246
|
+
configDirty = false;
|
|
1247
|
+
managedConfig = { ...managedConfig, content, revision: body.revision };
|
|
816
1248
|
await refreshProjectRegistry();
|
|
817
1249
|
renderProjects();
|
|
818
1250
|
},
|
|
819
1251
|
),
|
|
1252
|
+
saveForm: (formEl, configForm) =>
|
|
1253
|
+
projectMutation(
|
|
1254
|
+
configForm,
|
|
1255
|
+
() => {
|
|
1256
|
+
const patch = collectFormPatch(formEl, managedConfig.config ?? {});
|
|
1257
|
+
return patchProjectConfigApi(managedProject, patch, managedConfig.revision);
|
|
1258
|
+
},
|
|
1259
|
+
async (_body) => {
|
|
1260
|
+
configDirty = false;
|
|
1261
|
+
await openManagedProject(managedProject, { reload: true });
|
|
1262
|
+
},
|
|
1263
|
+
),
|
|
1264
|
+
previewMigration: async () => {
|
|
1265
|
+
try {
|
|
1266
|
+
const result = await getConfigMigrationPreview(managedProject, managedConfig.revision);
|
|
1267
|
+
migrationPreview = result;
|
|
1268
|
+
} catch (e) {
|
|
1269
|
+
migrationPreview = { error: e.message };
|
|
1270
|
+
}
|
|
1271
|
+
renderProjects();
|
|
1272
|
+
},
|
|
1273
|
+
applyMigration: async () => {
|
|
1274
|
+
const ok = await showConfirm(
|
|
1275
|
+
'Apply the config migration? This will update .changeledger/config.yml.',
|
|
1276
|
+
);
|
|
1277
|
+
if (!ok) return;
|
|
1278
|
+
try {
|
|
1279
|
+
await postConfigMigrationApply(managedProject, managedConfig.revision);
|
|
1280
|
+
await openManagedProject(managedProject, { reload: true });
|
|
1281
|
+
} catch (e) {
|
|
1282
|
+
migrationPreview = { error: e.message };
|
|
1283
|
+
renderProjects();
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
820
1286
|
repair: (projectPath, pathForm) =>
|
|
821
1287
|
projectMutation(
|
|
822
1288
|
pathForm,
|
|
@@ -826,13 +1292,13 @@ function renderProjects() {
|
|
|
826
1292
|
await openManagedProject(managedProject, { reload: true });
|
|
827
1293
|
},
|
|
828
1294
|
),
|
|
829
|
-
unregister: (editor) => {
|
|
1295
|
+
unregister: async (editor) => {
|
|
830
1296
|
const project = state.projectsList.find((item) => item.id === managedProject);
|
|
831
|
-
const
|
|
832
|
-
if (
|
|
1297
|
+
const answer = await requestUnregisterConfirmation(project);
|
|
1298
|
+
if (answer === null) return;
|
|
833
1299
|
projectMutation(
|
|
834
1300
|
editor,
|
|
835
|
-
() => postProjectRemove(managedProject,
|
|
1301
|
+
() => postProjectRemove(managedProject, answer),
|
|
836
1302
|
async () => {
|
|
837
1303
|
managedProject = null;
|
|
838
1304
|
managedConfig = null;
|
|
@@ -849,14 +1315,39 @@ export function bindProjectViewActions(root, handlers) {
|
|
|
849
1315
|
root.querySelectorAll('[data-manage-project]').forEach((button) => {
|
|
850
1316
|
button.onclick = () => handlers.select(button.dataset.manageProject);
|
|
851
1317
|
});
|
|
1318
|
+
|
|
1319
|
+
root.querySelectorAll('[data-config-mode]').forEach((button) => {
|
|
1320
|
+
button.onclick = () => handlers.switchMode(button.dataset.configMode);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
852
1323
|
const reload = root.querySelector('[data-reload-config]');
|
|
853
1324
|
if (reload) reload.onclick = () => handlers.reload();
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1325
|
+
|
|
1326
|
+
const rawForm = root.querySelector('.config-form:not([data-config-form])');
|
|
1327
|
+
if (rawForm) {
|
|
1328
|
+
rawForm.onsubmit = (event) => {
|
|
1329
|
+
event.preventDefault();
|
|
1330
|
+
handlers.saveRaw(rawForm.querySelector('textarea').value, rawForm);
|
|
1331
|
+
};
|
|
1332
|
+
rawForm.querySelector('textarea')?.addEventListener('input', () => handlers.markDirty?.());
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const formEditor = root.querySelector('[data-config-form]');
|
|
1336
|
+
if (formEditor) {
|
|
1337
|
+
formEditor.onsubmit = (event) => {
|
|
857
1338
|
event.preventDefault();
|
|
858
|
-
handlers.
|
|
1339
|
+
handlers.saveForm(formEditor, formEditor);
|
|
859
1340
|
};
|
|
1341
|
+
formEditor.addEventListener('input', () => handlers.markDirty?.());
|
|
1342
|
+
formEditor.addEventListener('change', () => handlers.markDirty?.());
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const previewBtn = root.querySelector('[data-preview-migration]');
|
|
1346
|
+
if (previewBtn) previewBtn.onclick = () => handlers.previewMigration();
|
|
1347
|
+
|
|
1348
|
+
const applyBtn = root.querySelector('[data-apply-migration]');
|
|
1349
|
+
if (applyBtn) applyBtn.onclick = () => handlers.applyMigration();
|
|
1350
|
+
|
|
860
1351
|
const pathForm = root.querySelector('.project-path-form');
|
|
861
1352
|
if (pathForm)
|
|
862
1353
|
pathForm.onsubmit = (event) => {
|
|
@@ -980,8 +1471,17 @@ function bootstrap() {
|
|
|
980
1471
|
$('#view-specs').onclick = () => activateView('specs');
|
|
981
1472
|
$('#view-metrics').onclick = () => activateView('metrics');
|
|
982
1473
|
$('#view-projects').onclick = () => activateView('projects');
|
|
983
|
-
$('#project').onchange = (e) => {
|
|
984
|
-
|
|
1474
|
+
$('#project').onchange = async (e) => {
|
|
1475
|
+
const nextProject = e.target.value;
|
|
1476
|
+
if (state.currentView === 'projects' && configDirty && nextProject !== state.currentProject) {
|
|
1477
|
+
const ok = await showConfirm('You have unsaved changes. Switch project and discard them?');
|
|
1478
|
+
if (!ok) {
|
|
1479
|
+
e.target.value = state.currentProject;
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
configDirty = false;
|
|
1483
|
+
}
|
|
1484
|
+
selectProject(nextProject);
|
|
985
1485
|
load();
|
|
986
1486
|
};
|
|
987
1487
|
document.onkeydown = (e) => {
|