Files
reflector-dashboard/lib/ui_list.js
T

274 lines
12 KiB
JavaScript

// Reflector Dashboard — web front-end for SvxReflector
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
// ui_list.js — shared list editor for every web (dmr-web, svx-web, svxlink-web).
//
// Canonical copy: <dmr-portal>/lib/ui_list.js. Other repos carry a
// plain COPY (never a symlink); deploy copies it in. Plain script, no deps
// beyond the shared CSS classes (btn, form-control, data-table, mono).
//
// One call renders an "Add + table + Delete" editor and hands the typed value
// back. The component owns look and validation; the page owns the storage
// format (JSON array, "9,240", "77.0:999,...") and the API key.
//
// const f = listField({
// kind: 'text' | 'int' | 'pair',
// value: [...], // text: string[] int: (number|string)[] pair: [a,b][]
// labels: ['Talk group'], // column header(s); pair takes two
// placeholder: '24016', // pair takes two: ['77.0', '999']
// addLabel: 'Add TG', // default 'Add'
// emptyText: 'No talk groups — click "Add TG".',
// upper: true, // text only: uppercase (callsigns)
// lower: true, // text only: lowercase (emails)
// validate: (s) => bool, // text only: extra per-row check (IPs)
// canEdit: true,
// onChange: (value) => {}, // called on every edit with get()
// });
// parent.appendChild(f.el); f.get(); f.set(newValue);
//
// get() returns the CLEAN value: trimmed, empty rows dropped, duplicates
// dropped, int rows parsed to positive integers, pairs with both halves.
// Rows that do not pass are shown with .is-invalid but left in place so the
// user can finish typing.
(function () {
'use strict';
function listField(opts) {
const kind = opts.kind || 'text';
const isPair = kind === 'pair';
const isInt = kind === 'int';
const canEdit = opts.canEdit !== false;
const labels = Array.isArray(opts.labels) ? opts.labels : [opts.labels || (isInt ? 'Value' : 'Text')];
const ph = Array.isArray(opts.placeholder) ? opts.placeholder : [opts.placeholder || '', ''];
const addLabel = opts.addLabel || 'Add';
const emptyText = opts.emptyText || ('None — click "' + addLabel + '".');
const onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
const validate = typeof opts.validate === 'function' ? opts.validate : null; // text: extra row check
// Working rows, always strings while editing.
let rows = normalize(opts.value);
function normalize(v) {
if (!Array.isArray(v)) v = (v == null || v === '') ? [] : [v];
return v.map(function (x) {
if (isPair) return Array.isArray(x) ? [str(x[0]), str(x[1])] : [str(x), ''];
return str(x);
});
}
function str(x) { return x == null ? '' : String(x).trim(); }
function cleanRow(r) { // -> typed value or null when not valid
if (isPair) {
const a = str(r[0]), b = str(r[1]);
return (a !== '' && b !== '') ? [a, b] : null;
}
let s = str(r);
if (s === '') return null;
if (isInt) {
if (!/^\d+$/.test(s)) return null;
const n = parseInt(s, 10);
return n > 0 ? n : null;
}
if (opts.upper) s = s.toUpperCase();
if (opts.lower) s = s.toLowerCase();
if (validate && !validate(s)) return null;
return s;
}
function get() {
const out = [], seen = new Set();
rows.forEach(function (r) {
const v = cleanRow(r);
if (v === null) return;
const key = isPair ? v[0] + ':' + v[1] : String(v);
if (seen.has(key)) return;
seen.add(key);
out.push(v);
});
return out;
}
const el = document.createElement('div');
el.className = 'ui-list';
const bar = document.createElement('div');
bar.className = 'd-flex gap-2 mb-2';
const btnAdd = document.createElement('button');
btnAdd.type = 'button';
btnAdd.className = 'btn btn-sm btn-outline-success';
btnAdd.textContent = addLabel;
btnAdd.disabled = !canEdit;
bar.appendChild(btnAdd);
el.appendChild(bar);
const tblWrap = document.createElement('div');
tblWrap.style.overflowX = 'auto';
el.appendChild(tblWrap);
function changed() { if (onChange) onChange(get()); }
function mkInput(value, placeholder, onInput) {
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm mono' + (opts.upper && !isPair && !isInt ? ' cs-upper' : '');
if (isInt) { inp.inputMode = 'numeric'; inp.pattern = '[0-9]*'; }
inp.value = value;
inp.placeholder = placeholder || '';
inp.disabled = !canEdit;
inp.addEventListener('input', function () { onInput(inp.value); });
return inp;
}
function draw() {
const tbl = document.createElement('table');
tbl.className = 'data-table';
tbl.style.minWidth = isPair ? '360px' : '280px';
const thead = document.createElement('thead');
const hr = document.createElement('tr');
labels.slice(0, isPair ? 2 : 1).forEach(function (l) {
const th = document.createElement('th'); th.textContent = l; hr.appendChild(th);
});
const thA = document.createElement('th'); thA.style.width = '120px'; thA.textContent = 'Action'; hr.appendChild(thA);
thead.appendChild(hr); tbl.appendChild(thead);
const tb = document.createElement('tbody');
if (!rows.length) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = isPair ? 3 : 2; td.className = 'small text-secondary'; td.textContent = emptyText;
tr.appendChild(td); tb.appendChild(tr);
}
rows.forEach(function (r, i) {
const tr = document.createElement('tr');
const mark = function () {
const ok = cleanRow(rows[i]) !== null;
tr.querySelectorAll('input').forEach(function (x) { x.classList.toggle('is-invalid', !ok); });
};
if (isPair) {
const tdA = document.createElement('td'), tdB = document.createElement('td');
tdA.appendChild(mkInput(r[0], ph[0], function (v) { rows[i][0] = v; mark(); changed(); }));
tdB.appendChild(mkInput(r[1], ph[1], function (v) { rows[i][1] = v; mark(); changed(); }));
tr.appendChild(tdA); tr.appendChild(tdB);
} else {
const td = document.createElement('td');
td.appendChild(mkInput(r, ph[0], function (v) { rows[i] = v; mark(); changed(); }));
tr.appendChild(td);
}
const tdX = document.createElement('td');
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-sm btn-outline-danger';
del.textContent = 'Delete';
del.disabled = !canEdit;
del.addEventListener('click', function () { rows.splice(i, 1); draw(); changed(); });
tdX.appendChild(del); tr.appendChild(tdX);
tb.appendChild(tr);
});
tbl.appendChild(tb);
tblWrap.innerHTML = '';
tblWrap.appendChild(tbl);
}
btnAdd.addEventListener('click', function () {
rows.push(isPair ? ['', ''] : '');
draw();
const ins = tblWrap.querySelectorAll('input');
if (ins.length) ins[isPair ? ins.length - 2 : ins.length - 1].focus();
});
draw();
return {
el: el,
get: get,
set: function (v) { rows = normalize(v); draw(); },
};
}
// listPopup: the compact form pattern — the page keeps a one-line text
// field, an "Edit…" button opens an overlay with the listField inside, Done
// (or a click outside the box) commits. This is the svxlink-web
// CTCSS_TO_TG shape; MONITOR_TGS and the dmr-web row lists use it too so
// every list edits the same way.
//
// listPopup({ title: 'CTCSS_TO_TG', list: {kind:'pair', ...}, onDone: (value) => {} })
function listPopup(opts) {
const old = document.getElementById('uiListPopup');
if (old) old.remove();
const ov = document.createElement('div');
ov.id = 'uiListPopup';
ov.style.cssText = 'position:fixed; inset:0; z-index:4000; background:rgba(0,0,0,.6); display:flex; align-items:flex-start; justify-content:center; overflow:auto; padding:40px 12px;';
const box = document.createElement('div');
box.style.cssText = 'background:var(--bg, #0a0a0a); color:var(--fg, #eee); border:1px solid var(--line, #333); border-radius:10px; padding:16px; min-width:360px; max-width:560px; width:100%;';
const head = document.createElement('div');
head.className = 'd-flex justify-content-between align-items-center mb-2';
const title = document.createElement('b');
title.textContent = opts.title || '';
const btnDone = document.createElement('button');
btnDone.type = 'button';
btnDone.className = 'btn btn-sm btn-outline-secondary';
btnDone.textContent = 'Done';
head.appendChild(title); head.appendChild(btnDone);
box.appendChild(head);
const f = listField(opts.list || {});
box.appendChild(f.el);
ov.appendChild(box);
const commit = function () {
ov.remove();
if (typeof opts.onDone === 'function') opts.onDone(f.get());
};
btnDone.addEventListener('click', commit);
ov.addEventListener('click', function (e) { if (e.target === ov) commit(); });
document.body.appendChild(ov);
const first = box.querySelector('input');
if (first) first.focus();
return { close: commit, field: f };
}
// listControl: the compact row control — a read-only summary field plus
// "Edit…" that opens listPopup. The page keeps the typed value; the
// control keeps the summary in sync and calls onDone(value) on Done.
//
// const c = listControl({ title, list: {kind, value, ...}, format: v => v.join(', '),
// canEdit: true, onDone: v => {...} });
// parent.appendChild(c.el); c.set(newValue);
function listControl(opts) {
const canEdit = opts.canEdit !== false;
const format = typeof opts.format === 'function' ? opts.format : function (v) { return v.join(', '); };
let current = Array.isArray(opts.list && opts.list.value) ? opts.list.value : [];
const el = document.createElement('div');
el.className = 'ui-list-control d-flex gap-2 align-items-center';
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm mono';
inp.readOnly = true;
inp.value = format(current);
inp.placeholder = opts.placeholder || '(none)';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-info';
btn.style.flex = 'none';
btn.textContent = 'Edit\u2026';
btn.disabled = !canEdit;
btn.addEventListener('click', function () {
listPopup({
title: opts.title || '',
list: Object.assign({}, opts.list || {}, { value: current, canEdit: canEdit }),
onDone: function (v) {
current = v;
inp.value = format(v);
if (typeof opts.onDone === 'function') opts.onDone(v);
},
});
});
el.appendChild(inp); el.appendChild(btn);
return { el: el, get: function () { return current; }, set: function (v) { current = Array.isArray(v) ? v : []; inp.value = format(current); } };
}
window.listField = listField;
window.listPopup = listPopup;
window.listControl = listControl;
})();