fix: simple prompt editing (save + restore original only), live users search, straight responsive admin rows
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 22s
Forgejo Android APK / Build signed APK (push) Successful in 1m55s

This commit is contained in:
Daniel 2026-09-07 19:30:40 +02:00
parent e7599128ec
commit f1ac24a17d
3 changed files with 70 additions and 182 deletions

View file

@ -1186,11 +1186,18 @@ textarea.full-input{resize:vertical;}
.btn-bio-login:active{transform:translateY(0);}
.btn-bio-login i{font-size:18px;}
/* Admin settings rows: aligned label/control pairs with consistent boxes. */
.admin-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.admin-row-label { font-size:13px; font-weight:600; color:var(--g700); min-width:130px; }
.admin-control { font-size:13px; font-family:inherit; padding:6px 10px; border:1px solid var(--g300); border-radius:6px; flex:1; max-width:420px; min-width:180px; background:white; color:var(--g800); }
/* Admin settings rows: straight aligned label/control pairs with consistent boxes. */
.admin-row { display:grid; grid-template-columns:minmax(140px,200px) minmax(0,1fr) auto; gap:10px; align-items:center; }
.admin-row-label { font-size:13px; font-weight:600; color:var(--g700); text-align:right; }
.admin-control { width:100%; font-size:13px; font-family:inherit; padding:6px 10px; border:1px solid var(--g300); border-radius:6px; max-width:420px; background:white; color:var(--g800); }
.admin-control:focus { outline:none; border-color:var(--blue); box-shadow:0 0 0 2px var(--blue-light); }
.cms-prompt-family > label { display:grid; grid-template-columns:minmax(140px,200px) minmax(0,1fr); gap:10px; align-items:center; }
@media (max-width:640px) {
.admin-row { grid-template-columns:1fr; }
.admin-row-label { text-align:left; }
.admin-control { max-width:none; }
.cms-prompt-family > label { grid-template-columns:1fr; }
}
/* One consistent font across the whole Admin panel. */
#admin-tab select, #admin-tab input, #admin-tab textarea, #admin-tab button,

View file

@ -149,6 +149,10 @@ function adminTabActive() {
.catch(function() { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); });
}
document.addEventListener('input', function(e) {
if (e.target && e.target.id === 'admin-users-search') filterUsers();
});
function renderUsers(users) {
var tbody = document.getElementById('admin-users-body');
if (!tbody) return;
@ -589,69 +593,47 @@ function adminTabActive() {
} finally { promptsLoading = false; }
}
// Compact per-family editor: one prompt select, one draft textarea, one status
// line, save/restore/history actions and a compact revision list for the
// SELECTED prompt. Drafts and revision baselines are kept per key in memory,
// so switching prompts (or families) never loses another prompt's unsaved draft.
// Simple prompt editor: pick a prompt, edit it, save it, or restore the
// shipped default. Revision tracking stays internal only so the server
// optimistic concurrency contract keeps working; no history UI is shown.
function createPromptFamilyEditor(family, prompts) {
var editor = document.createElement('div');
editor.className = 'cms-prompt-family';
editor.dataset.family = family;
editor.style.cssText = 'display:flex;flex-direction:column;gap:10px;';
// Only static markup is parsed; all catalogue/revision content is assigned as text.
// Only static markup is parsed; all catalogue content is assigned as text.
editor.innerHTML =
'<label style="font-size:13px;font-weight:600;">Prompt' +
'<select class="prompt-select" style="display:block;margin-top:4px;max-width:100%;font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;"></select></label>' +
'<select class="prompt-select" style="display:block;margin-top:4px;max-width:100%;font-size:13px;font-family:inherit;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;"></select></label>' +
'<textarea class="prompt-draft" rows="8" style="display:block;width:100%;box-sizing:border-box;font-family:inherit;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;"></textarea>' +
'<p class="prompt-baseline" style="margin:0;font-size:12px;color:var(--g500);"></p>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;">' +
'<button type="button" class="btn-sm btn-primary" data-prompt-action="save">Save prompt</button>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="reset">Restore original</button>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="history">History (latest 100)</button></div>' +
'<p class="prompt-status" role="status" style="margin:0;font-size:12px;color:var(--g600);"></p>' +
'<div class="prompt-history" hidden>' +
'<label>Saved revisions<select class="prompt-revisions" style="display:block;max-width:100%;"></select></label>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="view">View revision</button>' +
'<textarea class="prompt-revision-text" rows="4" readonly style="display:block;width:100%;box-sizing:border-box;font-family:inherit;font-size:12px;"></textarea>' +
'<p class="prompt-revision-meta"></p>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="restore">Restore viewed revision</button>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="baseline">Keep draft; use viewed current revision as save baseline</button></div>';
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="reset">Restore original</button></div>' +
'<p class="prompt-status" role="status" style="margin:0;font-size:12px;color:var(--g600);"></p>';
var select = editor.querySelector('.prompt-select');
var text = editor.querySelector('.prompt-draft');
var status = editor.querySelector('.prompt-status');
var history = editor.querySelector('.prompt-history');
var revisions = editor.querySelector('.prompt-revisions');
var preview = editor.querySelector('.prompt-revision-text');
var meta = editor.querySelector('.prompt-revision-meta');
var buttons = {};
editor.querySelectorAll('[data-prompt-action]').forEach(function(button) { buttons[button.dataset.promptAction] = button; });
// Per-key state: server value, optimistic save baseline, current server
// revision (from history) and the live unsaved draft.
// Per-key state: server value, revision baseline (internal only) and live draft.
var states = new Map();
prompts.forEach(function(p) {
var option = document.createElement('option');
option.value = p.dbKey;
option.textContent = p.key;
select.appendChild(option);
states.set(p.dbKey, { value: p.value, revision: p.revision, currentRevision: p.revision, draft: p.value });
states.set(p.dbKey, { value: p.value, revision: p.revision, draft: p.value });
});
var key = select.options.length ? select.options[0].value : null;
var viewedRevision = null;
var busy = false;
if (key) text.value = states.get(key).draft;
function state() { return key ? states.get(key) : null; }
function controls() {
Object.values(buttons).forEach(function(button) { button.disabled = busy; });
buttons.save.disabled = busy;
buttons.reset.disabled = busy;
select.disabled = busy;
revisions.disabled = busy;
buttons.view.disabled = busy || !revisions.value;
buttons.restore.disabled = busy || !viewedRevision;
buttons.baseline.disabled = busy || !viewedRevision || !state() || viewedRevision.id !== state().currentRevision;
var st = state();
editor.querySelector('.prompt-baseline').textContent = st ?
'Save baseline: revision ' + st.revision + (st.revision === 0 ? ' (no history yet).' : '.') : '';
}
async function run(task) {
if (busy) return;
@ -660,85 +642,31 @@ function adminTabActive() {
catch (error) { status.textContent = error.message + ' Unsaved edits are preserved.'; }
finally { busy = false; controls(); }
}
function clearPreview() {
viewedRevision = null; preview.value = ''; meta.textContent = ''; controls();
}
function revisionLabel(item) {
return '#' + item.id + ' — ' + item.createdAt + ' — ' + (item.createdBy == null ? 'unknown actor' : item.createdBy) +
(item.wasDefault ? ' — default snapshot' : '') + (item.restoredFrom == null ? '' : ' — restored from #' + item.restoredFrom);
}
function base() { return '/api/admin/config/prompts/' + encodeURIComponent(key); }
async function mutate(action, revisionId) {
async function mutate(action) {
var targetKey = key;
var st = states.get(targetKey);
var submitted = text.value;
st.draft = submitted;
var body = { expectedRevision: st.revision };
if (action === 'save') body.value = submitted;
if (action === 'restore') body.revisionId = revisionId;
var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : base() + '/' + action,
var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : '/api/admin/config/prompts/' + encodeURIComponent(targetKey) + '/reset',
action === 'save' ? 'PUT' : 'POST', body);
st.value = data.value;
st.revision = data.revision;
st.currentRevision = data.revision;
// Typing while a request is pending must not be overwritten by its response;
// switching away must not leak the response into another prompt's editor.
if (select.value === targetKey && text.value === submitted) {
text.value = data.value;
st.draft = data.value;
}
history.hidden = true; revisions.replaceChildren(); clearPreview();
status.textContent = 'Saved revision ' + data.revision + (text.value === data.value ? '.' : '. Newer draft edits are still unsaved.');
if (text.value === submitted) text.value = data.value;
status.textContent = action === 'save' ? 'Saved.' : 'Restored shipped default.';
}
select.onchange = function() {
// Stash the outgoing draft before switching: per-key drafts survive switches.
select.addEventListener('change', function() {
if (key) states.get(key).draft = text.value;
key = select.value;
var st = state();
text.value = st ? st.draft : '';
viewedRevision = null; preview.value = ''; meta.textContent = '';
history.hidden = true; revisions.replaceChildren();
text.value = states.get(key).draft;
status.textContent = '';
controls();
};
buttons.save.onclick = function() { run(function() { return mutate('save'); }); };
buttons.reset.onclick = function() {
showConfirm('Reset only this prompt to its shipped default? This replaces this editors draft and creates a revision; other editors are unchanged.', function() {
});
buttons.save.addEventListener('click', function() { run(function() { return mutate('save'); }); });
buttons.reset.addEventListener('click', function() {
showConfirm('Restore only this prompt to its shipped default? This replaces the current value.', function() {
run(function() { return mutate('reset'); });
});
};
buttons.history.onclick = function() { run(async function() {
var st = states.get(key);
var data = await promptRequest(base() + '/history?limit=100');
st.currentRevision = data.revision;
revisions.replaceChildren(); clearPreview();
(data.revisions || []).forEach(function(item) {
var option = document.createElement('option'); option.value = item.id; option.textContent = revisionLabel(item); revisions.appendChild(option);
});
history.hidden = false;
status.textContent = 'Current server revision: ' + st.currentRevision + '. ' + (revisions.options.length ? 'Select a revision to view. Your draft is unchanged.' : 'No saved revisions yet.');
}); };
revisions.onchange = clearPreview;
buttons.view.onclick = function() { run(async function() {
var data = await promptRequest(base() + '/revisions/' + encodeURIComponent(revisions.value));
viewedRevision = data.revision;
preview.value = viewedRevision.value;
meta.textContent = revisionLabel(viewedRevision);
status.textContent = 'Viewing revision ' + viewedRevision.id + '. Your draft is unchanged.';
}); };
buttons.restore.onclick = function() {
var id = viewedRevision && viewedRevision.id;
if (!id) return;
showConfirm('Restore revision ' + id + ' for only this prompt? This replaces this editors draft and creates a new revision.', function() {
run(function() { return mutate('restore', id); });
});
};
buttons.baseline.onclick = function() {
var st = state();
if (!viewedRevision || !st || viewedRevision.id !== st.currentRevision) return;
st.revision = st.currentRevision; controls();
status.textContent = 'Draft kept. The next save will use revision ' + st.revision + ' as its baseline; review your edits before saving.';
};
});
controls();
return editor;
}

View file

@ -79,7 +79,7 @@ test('three prompt sections keep Scribe, Clinical and Learning separate with ine
assert.equal(selectOf(scribe).value, catalogue[0].dbKey, 'first prompt selected by default');
assert.equal(selectOf(scribe).options[0].textContent, 'SCRIBE_0');
assert.equal(draftOf(scribe).value, unsafe);
for (const name of ['save', 'history', 'view', 'restore', 'reset', 'baseline']) assert.ok(actionOf(scribe, name));
for (const name of ['save', 'reset']) assert.ok(actionOf(scribe, name)); assert.equal(scribe.querySelector('.prompt-history'), null, 'no revision history UI');
for (const p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) {
const editor = sectionForPrompt(ui, p);
await choosePrompt(editor, p.dbKey);
@ -90,82 +90,59 @@ test('three prompt sections keep Scribe, Clinical and Learning separate with ine
assert.equal(call.url, '/api/admin/config/' + p.dbKey);
assert.equal(call.options.method, 'PUT');
assert.deepEqual(call.body, { value: '\n' + unsafe + '\n', expectedRevision: p.revision });
assert.match(statusOf(editor).textContent, /Saved revision 11/);
assert.match(statusOf(editor).textContent, /Saved/);
}
await choosePrompt(scribe, catalogue[2].dbKey);
assert.equal(draftOf(scribe).value, unsafe, 'other prompts unchanged');
});
test('history/view/restore/reset use exact per-key APIs for every family and never replace other drafts', async t => {
const historical = { id: 5, value: unsafe, createdAt: '2026-01-02T03:04:05Z', createdBy: '<img onerror=bad>', restoredFrom: 2, wasDefault: true };
test('restore original resets only the selected prompt and never replaces other drafts', async t => {
const ui = await browser(t, 'admin', (url, options) => {
if (url.endsWith('/history?limit=100')) return json({ success: true, revision: 10, revisions: [historical] });
if (url.endsWith('/revisions/5')) return json({ success: true, revision: historical });
if (url.endsWith('/restore')) return json({ success: true, value: unsafe, revision: 11 });
if (url.endsWith('/reset')) return json({ success: true, value: 'new shipped default', revision: 12 });
});
const scribe = sectionOf(ui, 'scribe');
const clinical = sectionOf(ui, 'clinical');
await choosePrompt(clinical, 'clinical_assistant.system_behavior');
draftOf(clinical).value = 'Unsaved clinical edits';
await choosePrompt(scribe, catalogue[1].dbKey);
draftOf(scribe).value = 'Unrelated unsaved Scribe draft';
await choosePrompt(scribe, catalogue[0].dbKey);
for (const p of [catalogue[0], ...catalogue.slice(-2)]) {
const f = sectionForPrompt(ui, p);
await choosePrompt(f, p.dbKey);
const base = '/api/admin/config/prompts/' + p.dbKey;
draftOf(f).value = 'Unsaved clinical/Scribe edits';
await clickAction(f, 'history');
assert.equal(ui.calls.at(-1).url, base + '/history?limit=100');
assert.match(f.querySelector('.prompt-revisions option').textContent, /default snapshot.*restored from #2/);
await clickAction(f, 'view');
assert.equal(ui.calls.at(-1).url, base + '/revisions/5');
assert.equal(f.querySelector('.prompt-revision-text').value, unsafe);
assert.equal(f.querySelector('.prompt-revision-text').readOnly, true);
assert.equal(draftOf(f).value, 'Unsaved clinical/Scribe edits');
assert.equal(actionOf(f, 'baseline').disabled, true, 'an old revision cannot rebase a stale save');
assert.equal(f.querySelector('script, img, [onerror]'), null);
await clickAction(f, 'restore');
assert.equal(ui.calls.at(-1).url, base + '/restore');
assert.equal(ui.calls.at(-1).options.method, 'POST');
assert.deepEqual(ui.calls.at(-1).body, { revisionId: 5, expectedRevision: 10 });
assert.equal(draftOf(f).value, unsafe);
await clickAction(f, 'reset');
assert.equal(ui.calls.at(-1).url, base + '/reset');
assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 11 });
assert.equal(draftOf(f).value, 'new shipped default');
}
await choosePrompt(clinical, 'clinical_assistant.image_behavior');
draftOf(clinical).value = 'Unsaved image edits';
await clickAction(clinical, 'reset');
assert.equal(ui.calls.at(-1).url, '/api/admin/config/prompts/clinical_assistant.image_behavior/reset');
assert.equal(ui.calls.at(-1).options.method, 'POST');
assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 10 });
assert.equal(draftOf(clinical).value, 'new shipped default');
await choosePrompt(scribe, catalogue[1].dbKey);
assert.equal(draftOf(scribe).value, 'Unrelated unsaved Scribe draft', 'switch never discards another prompt\'s draft');
assert.equal(draftOf(scribe).value, 'Unrelated unsaved Scribe draft', 'reset never touches another prompt');
await choosePrompt(clinical, 'clinical_assistant.system_behavior');
assert.equal(draftOf(clinical).value, 'Unsaved clinical edits', 'reset never touches another prompt in the same section');
});
test('409s preserve drafts; explicit review/rebase resolves conflicts without an automatic overwrite', async t => {
test('409s preserve drafts; revisiting the section refreshes the baseline for a clean save', async t => {
let conflicting = true;
const ui = await browser(t, 'admin', (url, options) => {
if (url.endsWith('/history?limit=100')) return json({ success: true, revision: 14, revisions: [{ id: 14, createdAt: 'now' }] });
if (url.endsWith('/revisions/14')) return json({ success: true, revision: { id: 14, value: 'Concurrent edit', createdAt: 'now' } });
if (options.method === 'PUT' || url.endsWith('/reset') || url.endsWith('/restore')) {
if (options.method === 'PUT' || url.endsWith('/reset')) {
if (conflicting) return json({ error: 'Stale revision' }, 409);
return json({ success: true, value: JSON.parse(options.body).value, revision: 15 });
}
});
const f = sectionOf(ui, 'clinical');
await choosePrompt(f, 'clinical_assistant.system_behavior');
draftOf(f).value = 'Keep this draft';
await clickAction(f, 'save');
assert.match(statusOf(f).textContent, /Conflict.*draft is unchanged/);
await clickAction(f, 'reset');
assert.equal(draftOf(f).value, 'Keep this draft');
await clickAction(f, 'history'); await clickAction(f, 'view');
await clickAction(f, 'restore');
assert.match(statusOf(f).textContent, /Conflict/);
assert.equal(draftOf(f).value, 'Keep this draft');
assert.match(f.querySelector('.prompt-baseline').textContent, /revision 10/);
await clickAction(f, 'baseline');
assert.equal(draftOf(f).value, 'Keep this draft');
assert.match(f.querySelector('.prompt-baseline').textContent, /revision 14/);
const clinical = sectionOf(ui, 'clinical');
await choosePrompt(clinical, 'clinical_assistant.system_behavior');
draftOf(clinical).value = 'Keep this draft';
await clickAction(clinical, 'save');
assert.match(statusOf(clinical).textContent, /Conflict.*draft is unchanged/);
assert.equal(draftOf(clinical).value, 'Keep this draft');
conflicting = false;
await clickAction(f, 'save');
assert.deepEqual(ui.calls.at(-1).body, { value: 'Keep this draft', expectedRevision: 14 });
assert.match(statusOf(f).textContent, /Saved revision 15/);
// Revisiting the tab reloads the catalogue and refreshes the internal baseline.
ui.document.dispatchEvent(new ui.window.CustomEvent('tabChanged', { detail: { tab: 'admin' } }));
await tick();
await choosePrompt(clinical, 'clinical_assistant.system_behavior');
draftOf(clinical).value = 'Keep this draft';
await clickAction(clinical, 'save');
assert.deepEqual(ui.calls.at(-1).body, { value: 'Keep this draft', expectedRevision: 10 });
assert.match(statusOf(clinical).textContent, /Saved/);
});
test('failed loads can retry; transport failures and in-flight saves preserve newer and unrelated drafts', async t => {
@ -186,8 +163,6 @@ test('failed loads can retry; transport failures and in-flight saves preserve ne
const f = sectionOf(ui, 'clinical');
await choosePrompt(f, 'clinical_assistant.image_behavior');
draftOf(f).value = 'Preserved draft';
await clickAction(f, 'history');
assert.match(statusOf(f).textContent, /Synthetic offline failure/);
await clickAction(f, 'save');
assert.equal(draftOf(f).value, 'Preserved draft');
assert.match(statusOf(f).textContent, /Migration unavailable/);
@ -198,32 +173,12 @@ test('failed loads can retry; transport failures and in-flight saves preserve ne
draftOf(f).value = 'Newer draft typed during save';
release(); await tick();
assert.equal(draftOf(f).value, 'Newer draft typed during save');
assert.match(statusOf(f).textContent, /Newer draft edits are still unsaved/);
assert.match(statusOf(f).textContent, /Saved/);
// Resetting non-prompt CMS settings also must not reload prompt editors.
ui.document.getElementById('btn-reset-all-defaults').click(); await tick();
assert.equal(draftOf(f).value, 'Newer draft typed during save');
});
test('empty history and failed revision viewing leave drafts intact and restore unavailable', async t => {
let empty = true;
const ui = await browser(t, 'admin', url => {
if (url.endsWith('/history?limit=100')) return json({ success: true, revision: empty ? 0 : 4, revisions: empty ? [] : [{ id: 4, createdAt: 'now' }] });
if (url.endsWith('/revisions/4')) return json({ error: 'Revision unavailable' }, 404);
});
const f = sectionOf(ui, 'scribe');
await choosePrompt(f, catalogue[1].dbKey);
draftOf(f).value = 'Keep even when no history is available';
await clickAction(f, 'history');
assert.match(statusOf(f).textContent, /No saved revisions yet/);
assert.equal(actionOf(f, 'view').disabled, true);
assert.equal(actionOf(f, 'restore').disabled, true);
empty = false;
await clickAction(f, 'history'); await clickAction(f, 'view');
assert.match(statusOf(f).textContent, /Revision unavailable/);
assert.equal(actionOf(f, 'restore').disabled, true);
assert.equal(draftOf(f).value, 'Keep even when no history is available');
});
async function loadChat(ui) {
const load = ui.document.querySelector('[data-assistant-load-chat="1"]');
assert.ok(load); load.click(); await tick();
@ -467,7 +422,6 @@ test('assistant settings retries leave global prompt drafts/history and starter
});
const f = sectionOf(ui, 'scribe');
draftOf(f).value = 'Unsaved global prompt';
f.querySelector('.prompt-revision-text').value = 'Previously viewed revision';
setting(ui, 'prompt-pool-snapshots').innerHTML = '<option value="7">Existing snapshot</option>';
const before = f.outerHTML;
const promptCalls = ui.calls.filter(c => c.url.includes('/config/prompts')).length;
@ -477,7 +431,6 @@ test('assistant settings retries leave global prompt drafts/history and starter
available = true;
adminVisit(ui); await tick();
assert.equal(draftOf(f).value, 'Unsaved global prompt');
assert.equal(f.querySelector('.prompt-revision-text').value, 'Previously viewed revision');
assert.equal(ui.calls.filter(c => c.url.includes('/config/prompts')).length, promptCalls);
assert.equal(ui.calls.some(c => c.options.method === 'POST' || c.options.method === 'PUT'), false);
});