phase 8 server-side validation (configSchema, inline field errors, partial-success semantics)

This commit is contained in:
2026-04-18 19:54:47 +00:00
parent 23a02c87d9
commit 39a5482516
5 changed files with 365 additions and 86 deletions

View File

@@ -970,3 +970,22 @@ button.ss-chip {
color: var(--primary);
}
/* ---------- Field-level validation errors (Phase 8) ---------- */
.field.field-error input,
.field.field-error select,
.field.field-error textarea,
.field.field-error .smart-select-display {
border-color: var(--danger);
box-shadow: 0 0 0 3px rgba(255, 90, 82, 0.15);
}
.field.field-error label { color: var(--danger); }
.field-error-message {
font-family: var(--font-body);
font-size: 12px;
color: var(--danger);
margin-top: 2px;
line-height: 1.4;
}

View File

@@ -80,9 +80,46 @@
updateSaveBar();
}
function clearFieldErrors() {
document.querySelectorAll('.field.field-error').forEach(f => f.classList.remove('field-error'));
document.querySelectorAll('.field-error-message').forEach(el => el.remove());
}
function normalizeErrorEntry(e) {
if (e && typeof e === 'object' && 'key' in e) {
return { key: e.key, message: e.error || 'Invalid value' };
}
// Tolerate legacy string shape "KEY: message" from older bot builds.
const str = String(e);
const idx = str.indexOf(':');
if (idx > 0) return { key: str.slice(0, idx).trim(), message: str.slice(idx + 1).trim() };
return { key: str, message: 'Invalid value' };
}
function applyFieldErrors(errors) {
let firstField = null;
for (const raw of errors) {
const { key, message } = normalizeErrorEntry(raw);
const selector = `[data-key="${(window.CSS && CSS.escape) ? CSS.escape(key) : key}"]`;
const input = document.querySelector(selector);
if (!input) continue;
const field = input.closest('.field');
if (!field) continue;
field.classList.add('field-error');
const msgEl = document.createElement('div');
msgEl.className = 'field-error-message';
msgEl.textContent = message;
msgEl.setAttribute('role', 'alert');
field.appendChild(msgEl);
if (!firstField) firstField = field;
}
if (firstField) firstField.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
async function saveConfig(mode) {
const buttons = document.querySelectorAll('#save-bar button');
buttons.forEach(b => b.disabled = true);
clearFieldErrors();
try {
if (mode === 'restart' && !confirm('Save changes and restart the bot now?')) {
return;
@@ -96,14 +133,21 @@
const data = await res.json();
if (data.applied) {
for (const key of data.applied) savedConfig[key] = pendingChanges[key];
pendingChanges = {};
for (const key of data.applied) delete pendingChanges[key];
updateSaveBar();
document.querySelectorAll('.changed').forEach(el => el.classList.remove('changed'));
Util.showToast(`${data.applied.length} settings saved.`, 'success');
document.querySelectorAll('.changed').forEach(el => {
const key = el.dataset && el.dataset.key;
if (!key || !(key in pendingChanges)) el.classList.remove('changed');
});
if (data.applied.length > 0) {
Util.showToast(`${data.applied.length} setting${data.applied.length === 1 ? '' : 's'} saved.`, 'success');
}
}
const hasErrors = data.errors && data.errors.length > 0;
if (hasErrors) {
Util.showToast(`Errors: ${data.errors.join(', ')}`, 'error');
applyFieldErrors(data.errors);
const keys = data.errors.map(e => normalizeErrorEntry(e).key).join(', ');
Util.showToast(`${data.errors.length} setting${data.errors.length === 1 ? '' : 's'} failed: ${keys}`, 'error');
}
if (mode === 'restart' && !hasErrors) {
await fetch('/api/restart', {
@@ -114,7 +158,7 @@
});
Util.showToast('Restart initiated.', 'warning');
} else if (mode === 'restart' && hasErrors) {
Util.showToast(`Restart cancelled — save returned errors: ${data.errors.join(', ')}`, 'warning');
Util.showToast('Restart cancelled — some settings failed validation.', 'warning');
}
} catch (e) {
Util.showToast('Failed to save. Bot may be unreachable.', 'error');