Files
electric-cherry/src/lib/ui/components/ConfigEditor.svelte
T
josh 8ac420fe61 Improve operational UX, recovery flows, and action safety
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.
2026-09-19 21:34:34 -04:00

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>