Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
156 lines
11 KiB
Svelte
156 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { confirmAction } from '$ui/confirm.js';
|
|
import { beforeNavigate, goto } from '$app/navigation';
|
|
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
|
import ConfigInspector from './ConfigInspector.svelte';
|
|
import Button from './primitives/Button.svelte';
|
|
import { configWarnings } from '$lib/config-workbench.js';
|
|
interface Version { id: number; version: number; notes: string | null; }
|
|
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
|
|
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
|
|
runningBetaId: number | null; scopeType: string | null; scopeValue: string | null; onsaved: () => Promise<void>;
|
|
} = $props();
|
|
let selected = $state('');
|
|
let text = $state('');
|
|
let original = $state('');
|
|
let notes = $state('');
|
|
let loading = $state(true);
|
|
let saving = $state(false);
|
|
let error = $state('');
|
|
let success = $state('');
|
|
let ready = $state(false);
|
|
let retry = $state(0);
|
|
let loadedVersion = $state('');
|
|
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
|
|
const dirty = $derived(text !== original || notes.trim().length > 0);
|
|
const ignoredWarnings = $derived(configWarnings(text, kind === 'config').filter(warning => warning.includes('ignored') || warning.includes('no value')));
|
|
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
|
|
onMount(() => {
|
|
selected = String(versions[0]?.id ?? ''); ready = true;
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
|
|
if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
|
|
} catch { /* Draft storage is optional. */ }
|
|
});
|
|
let allowNavigation = false;
|
|
beforeNavigate(({ cancel, to, type }) => {
|
|
if (!dirty || allowNavigation || type === 'leave' || !to) return;
|
|
cancel();
|
|
void confirmAction({title:'Leave this editor?', message:'You have unsaved changes. A draft is kept in this browser when storage is available.', confirmLabel:'Leave editor'}).then(async accepted => {
|
|
if (!accepted) return;
|
|
allowNavigation = true;
|
|
try { await goto(to.url); } finally { allowNavigation = false; }
|
|
});
|
|
});
|
|
function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
|
|
$effect(() => {
|
|
const version = selected;
|
|
const refresh = retry;
|
|
if (!ready || !version) return;
|
|
const controller = new AbortController();
|
|
loading = true; error = ''; loadedVersion = '';
|
|
(async () => {
|
|
try {
|
|
const response = await fetch(`/api/configs/${id}?version=${version}`, { signal: controller.signal });
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error ?? 'Could not load config contents.');
|
|
if (!controller.signal.aborted) { text = body.text; original = body.text; notes = ''; loadedVersion = version; }
|
|
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
|
|
finally { if (!controller.signal.aborted) loading = false; }
|
|
})();
|
|
return () => controller.abort();
|
|
});
|
|
$effect(() => {
|
|
if (!ready || loading || !loadedVersion || !dirty) return;
|
|
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ }
|
|
});
|
|
function clearDraft() { try { localStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } recovered = null; }
|
|
async function changeVersion(event: Event) {
|
|
const target = event.currentTarget as HTMLSelectElement;
|
|
const next = target.value; target.value = selected;
|
|
if (dirty && !await confirmAction({title:'Discard current edits?',message:'Open the selected version and discard your current changes.',confirmLabel:'Discard edits',danger:true})) return;
|
|
clearDraft(); selected = next; success = '';
|
|
}
|
|
function restoreDraft() {
|
|
if (!recovered) return;
|
|
// Keep the current version as the comparison base, including when the
|
|
// recovered draft was started from a version that has since been deleted.
|
|
text = recovered.text; notes = recovered.notes; recovered = null;
|
|
}
|
|
async function discard() { if (await confirmAction({title:'Discard unsaved changes?',message:'Restore the version you opened in this editor.',confirmLabel:'Discard changes',danger:true})) { text = original; notes = ''; clearDraft(); } }
|
|
async function importFile(event: Event) {
|
|
const input = event.currentTarget as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (file) { try { text = await file.text(); } catch { error = 'Could not read that file.'; } }
|
|
input.value = '';
|
|
}
|
|
async function save() {
|
|
if (saving || loading || runningBetaId !== null || !text.trim()) return;
|
|
saving = true; error = ''; success = '';
|
|
const form = new FormData();
|
|
form.append('content', new Blob([text], { type: 'text/plain' }), 'config.cfg');
|
|
if (notes.trim()) form.append('notes', notes.trim());
|
|
try {
|
|
const response = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(body.error ?? 'Could not save this version.');
|
|
original = text; notes = ''; clearDraft();
|
|
success = `v${body.version.version} saved as untested. Open a beta to test and promote it.`;
|
|
await onsaved(); selected = String(body.version.id);
|
|
} catch (err) { error = (err as Error).message; }
|
|
finally { saving = false; }
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onbeforeunload={beforeUnload} />
|
|
<section class="workbench" aria-label="Config editor">
|
|
<div class="editor-panel">
|
|
<div class="heading"><div><span class="eyebrow">Edit · preview · version</span><h2>Configuration editor</h2></div>
|
|
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
|
|
</div>
|
|
<div class="version-toolbar"><label>Starting version<select aria-label="Starting version" value={selected} onchange={changeVersion} disabled={saving || loading}>{#each versions as version}<option value={String(version.id)}>v{version.version}{version.id === currentVersionId ? ' · promoted' : ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option>{/each}</select></label>
|
|
<label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
|
|
</div>
|
|
{#if recovered && !loading}<div class="recovery"><span>A previous draft is available in this browser.</span><Button size="sm" onclick={restoreDraft} disabled={runningBetaId !== null}>Restore draft</Button><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button></div>{/if}
|
|
{#if success}<div class="success" role="status">{success} <a href={"/betas/new?artifact=" + id + ":" + selected}>Test this version →</a></div>{/if}
|
|
{#if error}<div class="error" role="alert">{error} {#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}</div>{/if}
|
|
{#if loading}<p class="muted" role="status">Loading config contents…</p>
|
|
{:else if loadedVersion}
|
|
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
|
|
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
|
|
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong class="save-warning">{ignoredWarnings.length} ignored or incomplete lines — review the open configuration notes before saving.</strong>{/if}</span>
|
|
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
|
|
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if loadedVersion && !loading}<ConfigInspector artifactId={id} draftText={text} {scopeType} {scopeValue} />{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.workbench { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--space-5); align-items: start; }
|
|
.editor-panel { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: var(--space-6); min-width: 0; }
|
|
.heading, .version-toolbar, .actions, .recovery { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
h2 { font-size: var(--text-lg); margin: 5px 0 0; }
|
|
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
|
|
.draft-tag { color: var(--state-warning); font-size: var(--text-xs); }
|
|
.version-toolbar { margin: 20px 0; align-items: flex-end; }
|
|
.version-toolbar > label:first-child { flex: 1; }
|
|
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-xs); }
|
|
select, input:not([type='file']) { width: 100%; box-sizing: border-box; padding: 10px 12px; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font: var(--text-sm) var(--font-body); }
|
|
input:focus-visible, select:focus-visible, .file-button:focus-within { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
|
.file-button { position: relative; display: block; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px; overflow: hidden; cursor: pointer; }
|
|
.file-button input { position: absolute; inset: 0; opacity: 0; width: 100%; cursor: pointer; }
|
|
.notes-label { margin-top: 20px; }
|
|
.actions { margin-top: 16px; }
|
|
.actions > span { flex: 1 1 200px; }
|
|
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
|
.save-warning { display: block; color: var(--state-warning); font-weight: 500; margin-top: 6px; }
|
|
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
|
|
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
|
|
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
|
|
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
|
|
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
|
</style>
|