Files

866 lines
41 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:a809a4a473181ef4f5b8a9d543bc95794a28448e3a837c586993056997db7bcb
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { installDom } from '../helpers/mini-dom.mjs';
const dom = installDom();
const { renderTable, configureTable, keyAction } = await import('../../client/ui/table.js');
let REPEAT_KEY = 'ctrl';
const converted = [];
configureTable({
repeatKey: () => REPEAT_KEY,
convert: { canConvert: a => !!(a && a.subunit), open: (trigger, o) => converted.push(o), icon: '<i class="swap"></i>' },
});
const tick = () => new Promise(r => setImmediate(r));
const ROWS = () => [
{ id: 1, name: 'Bob', kind: 'a', amount: 3, note: 'first' },
{ id: 2, name: '<Al>', kind: 'b', amount: 10, note: 'second' },
{ id: 3, name: 'Cy', kind: 'a', amount: null, note: 'third' },
];
const COLUMNS = () => [
{ key: 'name', label: 'Naam', sortable: true, text: true, edit: { type: 'text' } },
{ key: 'kind', label: 'Soort', group: { value: r => r.kind, label: v => `Soort ${v}` } },
{ key: 'amount', label: 'Bedrag', align: 'num', sortable: true, edit: { type: 'number' } },
];
function mount(extra = {}) {
const host = dom.host();
const cfg = { columns: COLUMNS(), rows: ROWS(), ...extra };
const api = renderTable(host, cfg);
return { host, api, cfg };
}
const ids = host => host.querySelectorAll('tr[data-row-id]').map(r => r.dataset.rowId);
const row = (host, id) => host.querySelector(`tr[data-row-id="${id}"]`);
const editRow = host => host.querySelector('[data-edit-row]');
const field = (host, key) => host.querySelector(`[data-field="${key}"]`);
const groups = host => host.querySelectorAll('.group-header').map(g => g.querySelector('span').textContent);
describe('renderTable: the frame and the cells', () => {
it('builds the frame, the header, one row per record and the record count', () => {
const { host } = mount();
assert.ok(host.querySelector('.table-frame > .table-controls-bar > .table-controls'));
assert.ok(host.querySelector('.table-frame > .table-wrap > table > thead'));
assert.deepEqual(host.querySelectorAll('thead th').map(th => th.textContent), ['Naam', 'Soort', 'Bedrag']);
assert.deepEqual(ids(host), ['1', '2', '3']);
assert.equal(host.querySelector('.tbl-count').textContent, '3');
});
it('escapes a value without render, and uses the markup of a render as it is', () => {
const cols = COLUMNS();
cols[2].render = r => `<b>${r.amount ?? '-'}</b>`;
const { host } = mount({ columns: cols });
const cell = row(host, 2).querySelectorAll('td')[0];
assert.equal(cell.textContent, '<Al>');
assert.equal(cell.querySelector('al'), null);
assert.equal(row(host, 2).querySelector('b').textContent, '10');
});
it('a text column wraps its content in .cell-clamp, a num column gets .num, every cell a data-label', () => {
const { host } = mount();
const tds = row(host, 1).querySelectorAll('td');
assert.ok(tds[0].classList.contains('text'));
assert.ok(tds[0].querySelector('.cell-clamp'));
assert.ok(tds[2].classList.contains('num'));
assert.equal(tds[2].querySelector('.cell-clamp'), null);
assert.deepEqual(tds.map(td => td.dataset.label), ['Naam', 'Soort', 'Bedrag']);
});
it('alternates a tint over DATA rows only', () => {
const { host } = mount();
assert.deepEqual(host.querySelectorAll('tr[data-row-id]').map(r => r.classList.contains('row-alt')), [false, true, false]);
});
it('no rows: one empty spacer row over all columns, and no message', () => {
const { host } = mount({ rows: [], onDelete: () => {} });
const spacer = host.querySelectorAll('tbody tr');
assert.equal(spacer.length, 1);
assert.ok(spacer[0].classList.contains('tbl-empty-spacer'));
assert.equal(spacer[0].querySelector('td').getAttribute('colspan'), '4');
});
it('an action column exists only when there is something to act on', () => {
assert.equal(mount().host.querySelector('th.actions-cell'), null);
assert.ok(mount({ onDelete: () => {} }).host.querySelector('th.actions-cell'));
assert.ok(mount({ extraActions: [{ key: 'x', label: 'X' }] }).host.querySelector('th.actions-cell'));
});
});
describe('renderTable: sorting', () => {
it('a click on a sortable header sorts ascending, a second click descending; empty stays last', () => {
const states = [];
const raw = [];
const { host } = mount({ onStateChange: s => { states.push({ ...s.sort }); raw.push(s.sort); } });
assert.equal(host.querySelector('th[data-sort="name"]') !== null, true);
assert.equal(host.querySelectorAll('th[data-sort]').length, 2);
dom.click(host.querySelector('th[data-sort="amount"]'));
assert.deepEqual(ids(host), ['1', '2', '3']);
assert.equal(host.querySelector('th[data-sort="amount"] .sort-arrow').textContent, '▲');
dom.click(host.querySelector('th[data-sort="amount"]'));
assert.deepEqual(ids(host), ['2', '1', '3']);
assert.equal(host.querySelector('th[data-sort="amount"] .sort-arrow').textContent, '▼');
assert.deepEqual(states, [{ key: 'amount', dir: 'asc' }, { key: 'amount', dir: 'desc' }]);
assert.notEqual(raw[0], raw[1]);
assert.equal(raw[0].dir, 'asc');
});
it('a stored sort on a column that no longer exists falls back on the default, and then on none', () => {
assert.deepEqual(ids(mount({ sort: { key: 'gone', dir: 'asc' }, defaultSort: { key: 'name', dir: 'desc' } }).host), ['3', '1', '2']);
assert.deepEqual(ids(mount({ sort: { key: 'gone', dir: 'asc' } }).host), ['1', '2', '3']);
});
it('sortValue decides the order instead of the raw value', () => {
const cols = COLUMNS();
cols[0].sortValue = r => -r.id;
assert.deepEqual(ids(mount({ columns: cols, sort: { key: 'name', dir: 'asc' } }).host), ['3', '2', '1']);
});
});
describe('renderTable: searching', () => {
it('filters on the rendered text, redraws only the body, and keeps the count on all records', () => {
const cols = COLUMNS();
cols[2].render = r => `<i>EUR ${r.amount}</i>`;
const { host } = mount({ columns: cols });
const box = host.querySelector('[data-tbl-search]');
box.focus();
dom.type(box, 'eur 10');
assert.deepEqual(ids(host), ['2']);
assert.equal(host.querySelector('[data-tbl-search]'), box, 'the search box is the same element');
assert.equal(host.querySelector('.tbl-count').textContent, '3');
dom.type(box, '');
assert.deepEqual(ids(host), ['1', '2', '3']);
});
it('a search that finds nothing leaves the empty spacer', () => {
const { host } = mount();
dom.type(host.querySelector('[data-tbl-search]'), 'zzz');
assert.ok(host.querySelector('tr.tbl-empty-spacer'));
});
});
describe('renderTable: grouping', () => {
it('a stored group draws a header per value, with escaped label and count', () => {
const cols = COLUMNS();
cols[1].group.label = v => `<${v}>`;
const { host } = mount({ columns: cols, group: 'kind' });
assert.deepEqual(groups(host), ['<a>', '<b>']);
assert.deepEqual(host.querySelectorAll('.group-count').map(c => c.textContent), ['2', '1']);
assert.deepEqual(ids(host), ['1', '3', '2']);
assert.equal(host.querySelector('[data-tbl-group="1"]').value, 'kind');
});
it('group.order desc turns the sections, and a sort on the group column decides instead', () => {
const cols = COLUMNS();
cols[1].group.order = 'desc';
assert.deepEqual(groups(mount({ columns: cols, group: 'kind' }).host), ['Soort b', 'Soort a']);
cols[1].sortable = true;
assert.deepEqual(groups(mount({ columns: cols, group: 'kind', sort: { key: 'kind', dir: 'asc' } }).host), ['Soort a', 'Soort b']);
});
it('three levels: the third select appears only when there is a column left for it', () => {
const cols = COLUMNS();
cols[0].group = { value: r => r.name[0] };
cols[2].group = { value: r => (r.amount == null ? 'leeg' : 'vol') };
const { host } = mount({ columns: cols, group: 'kind', group2: 'name', group3: 'amount' });
assert.ok(host.querySelector('[data-tbl-group="3"]'));
assert.deepEqual(groups(host), ['Soort a - B - vol', 'Soort a - C - leeg', 'Soort b - < - vol']);
});
it('a stored group on a column that cannot group is ignored', () => {
const { host } = mount({ group: 'name' });
assert.equal(host.querySelectorAll('.group-row').length, 0);
});
it('choosing in the select groups and stores; choosing none ungroups and drops the deeper levels', () => {
const states = [];
const cols = COLUMNS();
cols[0].group = { value: r => r.name[0] };
const { host } = mount({ columns: cols, onStateChange: s => states.push({ g: s.group, g2: s.group2 }) });
dom.choose(host.querySelector('[data-tbl-group="1"]'), 'kind');
assert.deepEqual(groups(host), ['Soort a', 'Soort b']);
dom.choose(host.querySelector('[data-tbl-group="2"]'), 'name');
assert.deepEqual(groups(host), ['Soort a - B', 'Soort a - C', 'Soort b - <']);
dom.choose(host.querySelector('[data-tbl-group="1"]'), '');
assert.equal(host.querySelectorAll('.group-row').length, 0);
assert.deepEqual(states.at(-1), { g: null, g2: null });
});
it('groupChoice false: grouped, but no select and no filter', () => {
const { host } = mount({ group: 'kind', groupChoice: false });
assert.equal(host.querySelectorAll('.group-row').length, 2);
assert.equal(host.querySelector('[data-tbl-group="1"]'), null);
assert.equal(host.querySelector('[data-gfilter]'), null);
});
it('the group filter hides a group, stores the HIDDEN token, and its chip stays', () => {
const states = [];
const { host } = mount({ group: 'kind', onStateChange: s => states.push(s.groupFilter) });
const chips = host.querySelectorAll('[data-gfilter] [data-fi]');
assert.equal(chips.length, 2);
dom.click(chips[1]);
assert.deepEqual(ids(host), ['1', '3']);
assert.deepEqual(states.at(-1), { kind: ['b'] });
assert.equal(host.querySelectorAll('[data-gfilter] [data-fi]').length, 2);
});
it('collapsible groups: a header click folds the rows away and shows the summary over all items', () => {
let summed = null;
const { host } = mount({ group: 'kind', collapsibleGroups: true, groupSummary: (items, v) => { summed = [v, items.length]; return '<em>sum</em>'; } });
dom.click(host.querySelector('[data-gcollapse="a"]'));
assert.deepEqual(ids(host), ['2']);
assert.ok(host.querySelector('.group-row.is-collapsed'));
assert.deepEqual(summed, ['a', 2]);
assert.equal(host.querySelector('.group-summary em').textContent, 'sum');
dom.click(host.querySelector('[data-gcollapse="a"]'));
assert.deepEqual(ids(host), ['1', '3', '2']);
});
it('a chip in a collapsible table cycles: open, collapsed, hidden, open', () => {
const { host } = mount({ group: 'kind', collapsibleGroups: true });
const chipA = () => host.querySelectorAll('[data-gfilter] [data-fi]')[0];
dom.click(chipA());
assert.deepEqual(ids(host), ['2']);
assert.ok(host.querySelector('[data-gcollapse="a"]'));
dom.click(chipA());
assert.equal(host.querySelector('[data-gcollapse="a"]'), null);
dom.click(chipA());
assert.deepEqual(ids(host), ['1', '3', '2']);
});
});
describe('renderTable: selecting and the row actions', () => {
it('a click selects a row and shows its actions; a second click deselects', () => {
const { host } = mount({ onSave: () => {}, onDelete: () => {}, extraActions: [{ key: 'x', label: 'X' }] });
dom.click(row(host, 2).querySelector('td'));
assert.ok(row(host, 2).classList.contains('row-selected'));
const acts = row(host, 2).querySelectorAll('.row-actions button');
assert.deepEqual(acts.map(b => b.dataset.action || (b.dataset.edit ? 'edit' : 'del')), ['x', 'edit', 'del']);
assert.equal(row(host, 1).querySelectorAll('.row-actions button').length, 0);
dom.click(row(host, 2).querySelector('td'));
assert.equal(row(host, 2).classList.contains('row-selected'), false);
});
it('rowEditable and rowDeletable suppress the buttons per row', () => {
const { host } = mount({ onSave: () => {}, onDelete: () => {}, rowEditable: r => r.id !== 1, rowDeletable: r => r.id !== 1 });
dom.click(row(host, 1).querySelector('td'));
assert.equal(row(host, 1).querySelectorAll('.row-actions button').length, 0);
});
it('delete and an extra action call back with a NUMBER id', () => {
const calls = [];
const { host } = mount({ onDelete: id => calls.push(['del', id]), onAction: (k, id) => calls.push([k, id]), extraActions: [{ key: 'x', label: 'X' }] });
dom.click(row(host, 3).querySelector('td'));
dom.click(host.querySelector('[data-action="x"]'));
dom.click(host.querySelector('[data-del]'));
assert.deepEqual(calls, [['x', 3], ['del', 3]]);
assert.equal(typeof calls[0][1], 'number');
});
it('a drawer: a chevron on every row, content on select, and the mount runs again after a redraw', () => {
const mounts = [];
const { host } = mount({ rowExpansion: r => `<p>drawer ${r.id}</p>`, onExpansionMount: (r, el) => mounts.push([r.id, el.localName]) });
assert.equal(host.querySelectorAll('td.exp-cell .chev-btn').length, 3);
assert.ok(host.querySelector('.table-wrap.has-exp.sticky-sel'));
dom.click(row(host, 1).querySelectorAll('td')[1]);
assert.equal(host.querySelector('tr.row-expansion[data-exp-id="1"] p').textContent, 'drawer 1');
assert.ok(row(host, 1).querySelector('.chev-btn.is-open'));
dom.click(host.querySelector('th[data-sort="name"]'));
assert.deepEqual(mounts, [[1, 'td'], [1, 'td']]);
});
});
describe('renderTable: editing an existing row', () => {
async function editing(extra = {}) {
const saved = [];
const t = mount({ onSave: async (id, v) => { saved.push([id, { ...v }]); }, ...extra });
dom.click(row(t.host, 1).querySelector('td'));
dom.click(t.host.querySelector('[data-edit]'));
await tick();
return { ...t, saved };
}
it('opens an edit row seeded from the record, focuses the first field, and locks the rest', async () => {
const { host } = await editing();
const er = editRow(host);
assert.ok(er);
assert.equal(field(host, 'name').value, 'Bob');
assert.equal(field(host, 'amount').value, '3');
assert.equal(dom.document.activeElement, field(host, 'name'));
assert.ok(host.querySelector('[data-tbl-search]').hasAttribute('disabled'));
dom.click(row(host, 2).querySelector('td'));
assert.equal(row(host, 2).classList.contains('row-selected'), false);
});
it('save hands the typed values to onSave and closes the row', async () => {
const { host, saved } = await editing();
field(host, 'name').value = 'Bobby';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.deepEqual(saved, [[1, { name: 'Bobby', amount: '3' }]]);
assert.equal(editRow(host), null);
});
it('a failing save keeps the row open and shows the message, escaped', async () => {
const { host } = await editing({ onSave: async () => { throw new Error('<nope>'); } });
dom.click(host.querySelector('[data-save]'));
await tick();
assert.ok(editRow(host));
assert.equal(host.querySelector('.edit-expand-row .form-error').textContent, '<nope>');
});
it('save and cancel stand in the drawer under the fields, not in the edit row (user, 26-10-2026)', async () => {
const { host } = await editing();
assert.equal(editRow(host).querySelector('button'), null);
const drawer = host.querySelector('tr.edit-expand-row');
assert.ok(drawer, 'a drawer under the edit row, also without drawer fields');
assert.ok(drawer.querySelector('.edit-actions [data-save]') && drawer.querySelector('.edit-actions [data-cancel]'));
});
it('cancel closes without saving', async () => {
const { host, saved } = await editing();
dom.click(host.querySelector('[data-cancel]'));
assert.equal(editRow(host), null);
assert.deepEqual(saved, []);
});
it('edit.value seeds instead of the raw field', async () => {
const cols = COLUMNS();
cols[0].edit.value = r => r.name.toUpperCase();
const { host } = await editing({ columns: cols });
assert.equal(field(host, 'name').value, 'BOB');
});
});
describe('renderTable: the edit controls', () => {
async function newRow(cols, extra = {}) {
const created = [];
const t = mount({ columns: cols, onCreate: async v => { created.push({ ...v }); }, ...extra });
dom.click(t.host.querySelector('[data-tbl-new]'));
await tick();
return { ...t, created };
}
it('a select gets its options from an async callback, and shows the current value', async () => {
const cols = COLUMNS();
cols[1].edit = { type: 'select', options: async vals => [{ value: 'a', label: 'A' }, 'b'].concat(vals.kind ? [] : []) };
const { host } = await newRow(cols, { newDefaults: { kind: 'b' } });
const sel = field(host, 'kind');
assert.deepEqual(sel.querySelectorAll('option').map(o => [o.value, o.textContent]), [['a', 'A'], ['b', 'b']]);
assert.equal(sel.value, 'b');
});
it('a checkbox is read as a boolean', async () => {
const cols = [...COLUMNS(), { key: 'on', label: 'Aan', edit: { type: 'checkbox' } }];
const { host, created } = await newRow(cols);
field(host, 'on').checked = true;
dom.click(host.querySelector('[data-save]'));
await tick();
assert.equal(created[0].on, true);
});
it('a hidden field (edit.visible) keeps its value, and an expand field sits in the drawer row', async () => {
const cols = COLUMNS();
cols[2].edit.visible = () => false;
cols.push({ key: 'note', label: 'Notitie', edit: { type: 'textarea', expand: true } });
const { host, created } = await newRow(cols, { newDefaults: { amount: 7 } });
assert.equal(field(host, 'amount'), null);
assert.ok(host.querySelector('.edit-expand-row [data-field="note"]'));
host.querySelector('[data-field="note"]').value = 'n';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.equal(created[0].amount, 7);
assert.equal(created[0].note, 'n');
});
it('a reactive change hands a copy, the field and the touched set to onEditChange, and applies its patch', async () => {
const seen = [];
const cols = COLUMNS();
cols[0].edit.reactive = true;
const { host } = await newRow(cols, {
onEditChange: (vals, key, touched) => { seen.push([vals.name, key, [...touched]]); return { amount: vals.name.length }; },
});
const name = field(host, 'name');
name.value = 'abcd';
dom.fire(name, 'input');
dom.fire(name, 'change');
await tick();
assert.deepEqual(seen, [['abcd', 'name', ['name']]]);
assert.equal(field(host, 'amount').value, '4');
});
it('options as plain strings become value and label (the integrations pages pass HTTP methods that way)', async () => {
const cols = COLUMNS();
cols[1].edit = { type: 'select', options: ['GET', 'POST'] };
const { host } = await newRow(cols);
assert.deepEqual(field(host, 'kind').querySelectorAll('option').map(o => [o.value, o.textContent]), [['GET', 'GET'], ['POST', 'POST']]);
});
it('a placeholder may be a function of the values, and attrs land escaped on the control', async () => {
const cols = COLUMNS();
cols[0].edit = { type: 'text', placeholder: v => `voor ${v.kind}`, attrs: { maxlength: '5', 'data-x': 'a"b' } };
const { host } = await newRow(cols, { newDefaults: { kind: 'k' } });
const f = field(host, 'name');
assert.equal(f.getAttribute('placeholder'), 'voor k');
assert.equal(f.getAttribute('maxlength'), '5');
assert.equal(f.getAttribute('data-x'), 'a"b');
});
it('edit.render supplies its own control, and edit.read reads it back', async () => {
const cols = COLUMNS();
cols[0].edit = { render: v => `<span data-field="name"><input class="own" value="${v.name ?? ''}"></span>`, read: host => host.querySelector('.own').value + '!' };
const { host, created } = await newRow(cols, { newDefaults: { name: 'x' } });
host.querySelector('.own').value = 'y';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.equal(created[0].name, 'y!');
});
it('a convert trigger appears only when the chosen asset has a subunit', async () => {
const cols = COLUMNS();
cols[2].edit = { type: 'number', convert: v => (v.kind === 'btc' ? { subunit: 'sat' } : { subunit: '' }) };
const withSub = await newRow(cols, { newDefaults: { kind: 'btc' }, convertLabel: 'Omrekenen' });
assert.ok(withSub.host.querySelector('.conv-field [data-convert="amount"]'));
assert.equal(withSub.host.querySelector('[data-convert]').title, 'Omrekenen');
const without = await newRow(cols, { newDefaults: { kind: 'eur' } });
assert.equal(without.host.querySelector('[data-convert]'), null);
});
it('the focus survives the redraw of a reactive change', async () => {
const cols = COLUMNS();
cols[2].edit.reactive = true;
const { host } = await newRow(cols);
const amount = field(host, 'amount');
amount.focus();
dom.choose(amount, '5');
await tick();
assert.ok(field(host, 'amount') !== amount, 'redrawn');
assert.ok(dom.document.activeElement === field(host, 'amount'), 'the focus is on the new amount field');
});
});
describe('renderTable: new records', () => {
it('the plus opens an edit row on top with the defaults, and save calls onCreate', async () => {
const created = [];
const { host } = mount({ onCreate: async v => { created.push({ ...v }); }, newDefaults: { amount: 1 } });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.equal(host.querySelector('tbody tr'), editRow(host));
assert.equal(field(host, 'amount').value, '1');
field(host, 'name').value = 'Dee';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.deepEqual(created, [{ name: 'Dee', amount: '1' }]);
});
it('createHidden as a function: the plus goes once the last allowed row is saved, and repeat opens nothing', async () => {
let used = 0;
const { host } = mount({ onCreate: async () => { used++; }, createHidden: () => used >= 1 });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
field(host, 'name').value = 'Dee';
dom.key(field(host, 'name'), 'Enter', { ctrlKey: true });
await tick();
await tick();
assert.equal(used, 1);
assert.equal(editRow(host), null, 'na de laatste toegestane rij gaat er geen nieuwe open');
assert.equal(host.querySelector('[data-tbl-new]'), null);
});
it('newOptions: the plus opens a menu, and a choice opens a row with its own defaults', async () => {
const { host } = mount({ onCreate: async () => {}, newOptions: [{ label: 'Eén', defaults: { amount: 1 } }, { label: 'Twee', defaults: { amount: 2 } }] });
dom.click(host.querySelector('[data-tbl-new]'));
const menu = dom.document.querySelector('.new-menu');
assert.deepEqual(menu.querySelectorAll('[data-new-opt]').map(b => b.textContent), ['Eén', 'Twee']);
dom.click(menu.querySelectorAll('[data-new-opt]')[1]);
await tick();
assert.equal(field(host, 'amount').value, '2');
assert.equal(dom.document.querySelector('.new-menu'), null);
});
it('a sticky column remembers the value of the last CREATED record, and reports it', async () => {
const reported = [];
const cols = COLUMNS();
cols[0].edit.sticky = true;
const { host } = mount({ columns: cols, onCreate: async () => {}, onStickyChange: v => reported.push(v) });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
field(host, 'name').value = 'Eve';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.deepEqual(reported, [{ name: 'Eve' }]);
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.equal(field(host, 'name').value, 'Eve');
assert.equal(field(host, 'amount').value, '');
});
it('a sticky column starts from cfg.sticky; a failing create remembers nothing', async () => {
const reported = [];
const cols = COLUMNS();
cols[0].edit.sticky = true;
const { host } = mount({ columns: cols, sticky: { name: 'Kept' }, onCreate: async () => { throw new Error('x'); }, onStickyChange: v => reported.push(v) });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.equal(field(host, 'name').value, 'Kept');
field(host, 'name').value = 'Other';
dom.click(host.querySelector('[data-save]'));
await tick();
assert.deepEqual(reported, []);
});
});
describe('renderTable: the keyboard in the edit row', () => {
async function newRowWith(extra = {}) {
const created = [];
const t = mount({ onCreate: async v => { created.push({ ...v }); }, ...extra });
dom.click(t.host.querySelector('[data-tbl-new]'));
await tick();
return { ...t, created };
}
it('Enter saves and closes; Escape cancels', async () => {
const a = await newRowWith();
field(a.host, 'name').value = 'K';
dom.key(field(a.host, 'name'), 'Enter');
await tick();
assert.equal(a.created.length, 1);
assert.equal(editRow(a.host), null);
const b = await newRowWith();
dom.key(field(b.host, 'name'), 'Escape');
assert.equal(editRow(b.host), null);
assert.equal(b.created.length, 0);
});
it('Ctrl+Enter saves and opens the next row; a refresh with locate from inside onCreate does not interfere', async () => {
let api;
const created = [];
const t = mount({ onCreate: async v => { created.push({ ...v }); api.refresh([...ROWS(), { id: 9, name: v.name, kind: 'a', amount: 1 }], { locate: 9 }); } });
api = t.api;
dom.click(t.host.querySelector('[data-tbl-new]'));
await tick();
field(t.host, 'name').value = 'R';
dom.document.scrolledIntoView.length = 0;
dom.key(field(t.host, 'name'), 'Enter', { ctrlKey: true });
await tick();
await tick();
dom.flushFrames();
assert.deepEqual(dom.document.scrolledIntoView.map(s => s.el.dataset.rowId), []);
assert.equal(created.length, 1);
assert.ok(editRow(t.host), 'a fresh edit row is open');
assert.equal(field(t.host, 'name').value, '');
assert.equal(t.host.querySelector('.row-selected'), null);
assert.ok(row(t.host, 9), 'the rows were taken');
});
it('with repeat_entry_key "enter" the two Enters swap', async () => {
REPEAT_KEY = 'enter';
try {
const t = await newRowWith();
dom.key(field(t.host, 'name'), 'Enter');
await tick();
await tick();
assert.equal(t.created.length, 1);
assert.ok(editRow(t.host));
} finally { REPEAT_KEY = 'ctrl'; }
});
it('keys outside the edit row do nothing', () => {
const { host } = mount({ onCreate: async () => {} });
assert.equal(dom.key(host.querySelector('[data-tbl-search]'), 'Enter'), true, 'not prevented');
});
});
describe('renderTable: the toolbar', () => {
it('a note, and extra actions with and without an icon that call back with their button', () => {
const calls = [];
const { host } = mount({
toolbarNote: '1 van <1>',
toolbarActions: [{ id: 'imp', label: 'Import', icon: '<svg></svg>' }, { id: 'exp', label: 'Export' }],
onToolbarAction: (id, b) => calls.push([id, b.localName]),
});
assert.equal(host.querySelector('.tbl-note').textContent, '1 van <1>');
assert.ok(host.querySelector('[data-tbl-act="imp"].btn-new svg'));
assert.equal(host.querySelector('[data-tbl-act="exp"]').textContent, 'Export');
dom.click(host.querySelector('[data-tbl-act="exp"]'));
assert.deepEqual(calls, [['exp', 'button']]);
});
it('while editing, the toolbar is disabled and its actions do nothing', async () => {
const calls = [];
const { host } = mount({ onCreate: async () => {}, toolbarActions: [{ id: 'exp', label: 'Export' }], onToolbarAction: id => calls.push(id) });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.ok(host.querySelector('[data-tbl-act="exp"]').hasAttribute('disabled'));
dom.click(host.querySelector('[data-tbl-act="exp"]'));
assert.deepEqual(calls, []);
});
});
describe('renderTable: refresh, focusGroup, focusRow, clearHighlight', () => {
it('refresh takes new rows and redraws', () => {
const { host, api } = mount();
api.refresh([{ id: 7, name: 'Zed', kind: 'c', amount: 1 }]);
assert.deepEqual(ids(host), ['7']);
});
it('refresh with locate selects the record, clears the search, opens its hidden group and stores that, then scrolls', () => {
const states = [];
const { host, api } = mount({ group: 'kind', groupFilter: { kind: ['b'] }, onStateChange: s => states.push(s.groupFilter) });
dom.type(host.querySelector('[data-tbl-search]'), 'bob');
dom.document.scrolledIntoView.length = 0;
api.refresh(null, { locate: 2 });
assert.ok(row(host, 2).classList.contains('row-selected'));
assert.equal(host.querySelector('[data-tbl-search]').value, '');
assert.deepEqual(states.at(-1), { kind: [] });
dom.flushFrames();
assert.equal(dom.document.scrolledIntoView.at(-1).el.dataset.rowId, '2');
});
it('focusGroup groups on the column and opens the group of the matching row, WITHOUT storing', () => {
const states = [];
const { host, api } = mount({ groupFilter: { kind: ['b'] }, onStateChange: s => states.push(s) });
assert.equal(api.focusGroup('kind', r => r.id === 2), true);
assert.deepEqual(groups(host), ['Soort a', 'Soort b']);
assert.ok(row(host, 2));
assert.deepEqual(states, []);
assert.equal(api.focusGroup('kind', () => false), false);
assert.equal(api.focusGroup('name', () => true), false, 'not a group column');
});
it('focusRow selects one record, also through a string id, WITHOUT storing; an unknown id changes nothing', () => {
const states = [];
const { host, api } = mount({ onStateChange: s => states.push(s) });
assert.equal(api.focusRow('3'), true);
assert.ok(row(host, 3).classList.contains('row-selected'));
assert.equal(api.focusRow(99), false);
assert.ok(row(host, 3).classList.contains('row-selected'));
assert.equal(api.focusRow(null), false);
assert.deepEqual(states, []);
});
it('clearHighlight deselects and closes an edit row', async () => {
const { host, api } = mount({ onCreate: async () => {} });
dom.click(row(host, 1).querySelector('td'));
api.clearHighlight();
assert.equal(host.querySelector('.row-selected'), null);
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
api.clearHighlight();
assert.equal(editRow(host), null);
});
});
describe('renderTable: the options of Services', () => {
const store = new Map();
globalThis.localStorage = { getItem: k => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)) };
it('a button, link or field in a cell is that control\'s: a click on it selects nothing (OPEN punt 7)', () => {
const cols = COLUMNS();
cols[0].render = r => `<span class="plain">${r.name}</span><button class="own">b</button><a class="own-link" href="#x">a</a><input class="own-field">`;
const { host } = mount({ columns: cols, onDelete() {}, rowExpansion: r => `<p>${r.id}</p>` });
for (const sel of ['.own', '.own-link', '.own-field']) {
dom.click(row(host, 1).querySelector(sel));
assert.equal(host.querySelector('.row-selected'), null, `a click on ${sel} selected the row`);
}
dom.click(row(host, 1).querySelector('.plain'));
assert.ok(row(host, 1).classList.contains('row-selected'), 'a click on the rest of the row selects');
dom.click(row(host, 1).querySelector('.chev-btn'));
assert.equal(host.querySelector('.row-selected'), null, 'the chevron is the row\'s hint and toggles it');
});
it('a row is selectable only when that does something: actions or a drawer', () => {
const { host } = mount();
dom.click(row(host, 1).querySelector('td'));
assert.equal(host.querySelector('.row-selected'), null);
});
it('stateKey keeps sort and grouping in localStorage, and a later table starts from it', () => {
configureTable({ repeatKey: () => REPEAT_KEY, storagePrefix: 'test.' });
const first = mount({ stateKey: 'k1' });
dom.click(first.host.querySelector('th[data-sort="name"]'));
dom.choose(first.host.querySelector('[data-tbl-group="1"]'), 'kind');
assert.deepEqual(JSON.parse(store.get('test.k1')), { sort: { key: 'name', dir: 'asc' }, group: 'kind' });
const second = mount({ stateKey: 'k1' });
assert.deepEqual(groups(second.host), ['Soort a', 'Soort b']);
assert.equal(second.host.querySelector('th[data-sort="name"]').getAttribute('aria-sort'), 'ascending');
});
it('a stored group of null means ungrouped on purpose, over the configured group', () => {
store.set('test.k2', JSON.stringify({ sort: null, group: null }));
assert.equal(mount({ stateKey: 'k2', group: 'kind' }).host.querySelectorAll('.group-row').length, 0);
store.set('test.k3', JSON.stringify({ sort: { key: 'gone', dir: 'asc' }, group: 'gone' }));
const fallback = mount({ stateKey: 'k3', group: 'kind', sort: { key: 'amount', dir: 'desc' } });
assert.equal(fallback.host.querySelectorAll('.group-row').length, 2, 'a stored group that does not fit falls back');
assert.equal(fallback.host.querySelector('th[data-sort="amount"] .sort-arrow').textContent, '▼');
configureTable({ repeatKey: () => REPEAT_KEY });
});
it('rowClass adds its class; onDraw runs after every draw and every search', () => {
let draws = 0;
const { host } = mount({ rowClass: r => (r.id === 2 ? 'is-muted' : ''), onDraw: () => { draws++; } });
assert.ok(row(host, 2).classList.contains('is-muted'));
assert.equal(row(host, 1).classList.contains('is-muted'), false);
dom.type(host.querySelector('[data-tbl-search]'), 'bob');
assert.equal(draws, 2);
});
it('the texts: countLabel, groupLabel and searchLabel per table, escaped', () => {
const { host } = mount({ countLabel: 'Rijen', groupLabel: 'Groeperen <op>', searchLabel: 'Zoek "x"' });
assert.equal(host.querySelector('.tbl-count').title, 'Rijen');
assert.equal(host.querySelector('[data-tbl-group="1"]').getAttribute('aria-label'), 'Groeperen <op>');
assert.equal(host.querySelector('[data-tbl-search]').getAttribute('placeholder'), 'Zoek "x"');
});
it('the keyboard: Enter on a sortable header sorts, Enter or Space on a row with a drawer opens it', () => {
const { host } = mount({ rowExpansion: r => `<p>${r.id}</p>` });
assert.equal(row(host, 1).getAttribute('tabindex'), '0');
dom.key(host.querySelector('th[data-sort="amount"]'), 'Enter');
assert.equal(host.querySelector('th[data-sort="amount"]').getAttribute('aria-sort'), 'ascending');
dom.key(row(host, 2), ' ');
assert.equal(row(host, 2).getAttribute('aria-expanded'), 'true');
assert.ok(host.querySelector('tr.row-expansion[data-exp-id="2"]'));
});
it('string ids, also with a quote in them, select and open their drawer; callbacks get the id as the row has it', () => {
const rows = [{ id: 'a"1', name: 'X', kind: 'a', amount: 1 }, { id: 'b', name: 'Y', kind: 'b', amount: 2 }];
const mounted = [];
const deleted = [];
const { host } = mount({ rows, rowExpansion: () => '<p>d</p>', onExpansionMount: r => mounted.push(r.id), onDelete: id => deleted.push(id) });
dom.click(host.querySelectorAll('tr[data-row-id]')[0].querySelectorAll('td')[1]);
assert.deepEqual(mounted, ['a"1']);
dom.click(host.querySelector('[data-del]'));
assert.deepEqual(deleted, ['a"1']);
});
it('a refresh without the selected row drops the selection', () => {
const { host, api } = mount({ onDelete() {} });
dom.click(row(host, 2).querySelector('td'));
api.refresh(ROWS().filter(r => r.id !== 2));
api.refresh(ROWS());
assert.equal(host.querySelector('.row-selected'), null);
});
it('an empty table shows no text, also with emptyText (decided 19-10-2026)', () => {
const { host } = mount({ rows: [], emptyText: 'Nog niets.' });
assert.ok(host.querySelector('tr.tbl-empty-spacer'));
assert.doesNotMatch(host.textContent, /Nog niets/);
});
it('labels are text: a column label with markup is shown, not parsed', () => {
const cols = COLUMNS();
cols[0].label = '<b>Naam</b>';
const { host } = mount({ columns: cols });
assert.equal(host.querySelector('th[data-sort="name"]').textContent, '<b>Naam</b>');
});
});
describe('renderTable: configureTable', () => {
it('the default texts are English, and an app sets its own', () => {
configureTable({ repeatKey: () => REPEAT_KEY });
const en = mount({ onCreate: async () => {}, group: 'kind' });
assert.equal(en.host.querySelector('[data-tbl-new]').title, 'New');
assert.equal(en.host.querySelector('[data-tbl-group="1"] option').textContent, 'Ungrouped');
configureTable({ repeatKey: () => REPEAT_KEY, labels: { new: 'Nieuw', none: 'Ongegroepeerd' } });
const nl = mount({ onCreate: async () => {}, group: 'kind' });
assert.equal(nl.host.querySelector('[data-tbl-new]').title, 'Nieuw');
assert.equal(nl.host.querySelector('[data-tbl-group="1"] option').textContent, 'Ongegroepeerd');
configureTable({
repeatKey: () => REPEAT_KEY,
convert: { canConvert: a => !!(a && a.subunit), open: (trigger, o) => converted.push(o), icon: '<i class="swap"></i>' },
});
});
it('the convert trigger opens the app\'s conversion with the asset and the value, and applies its answer', async () => {
const cols = COLUMNS();
cols[2].edit = { type: 'number', convert: () => ({ subunit: 'sat' }) };
const { host } = mount({ columns: cols, onCreate: async () => {} });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
field(host, 'amount').value = '5';
converted.length = 0;
dom.click(host.querySelector('[data-convert="amount"]'));
assert.equal(converted.length, 1);
assert.equal(converted[0].value, '5');
assert.deepEqual(converted[0].asset, { subunit: 'sat' });
converted[0].onApply('0.00000005');
assert.equal(field(host, 'amount').value, '0.00000005');
});
it('without a convert hook there is no trigger', async () => {
configureTable({ repeatKey: () => REPEAT_KEY });
const cols = COLUMNS();
cols[2].edit = { type: 'number', convert: () => ({ subunit: 'sat' }) };
const { host } = mount({ columns: cols, onCreate: async () => {} });
dom.click(host.querySelector('[data-tbl-new]'));
await tick();
assert.equal(host.querySelector('[data-convert]'), null);
configureTable({
repeatKey: () => REPEAT_KEY,
convert: { canConvert: a => !!(a && a.subunit), open: (trigger, o) => converted.push(o), icon: '<i class="swap"></i>' },
});
});
});
describe('keyAction', () => {
const ctx = (over = {}) => ({ key: 'Enter', ctrl: false, tag: 'INPUT', isButton: false, popoverOpen: false, dialogOpen: false, repeatKey: 'ctrl_enter', ...over });
it('Enter saves, Ctrl+Enter saves and opens the next row, and the preference swaps them', () => {
assert.equal(keyAction(ctx()), 'save');
assert.equal(keyAction(ctx({ tag: 'SELECT' })), 'save');
assert.equal(keyAction(ctx({ ctrl: true })), 'saveNext');
assert.equal(keyAction(ctx({ repeatKey: 'enter' })), 'saveNext');
assert.equal(keyAction(ctx({ ctrl: true, repeatKey: 'enter' })), 'save');
});
it('an unreadable preference means the default, never a repeat by accident', () => {
for (const repeatKey of [undefined, null, '', 'nonsense']) {
assert.equal(keyAction(ctx({ repeatKey })), 'save');
assert.equal(keyAction(ctx({ ctrl: true, repeatKey })), 'saveNext');
}
});
it('Escape cancels; every other key leaves the table alone', () => {
assert.equal(keyAction(ctx({ key: 'Escape' })), 'cancel');
assert.equal(keyAction(ctx({ key: 'Escape', tag: 'TEXTAREA' })), 'cancel');
for (const key of ['Tab', 'a', 'ArrowDown', ' ', 'Backspace', 'F5']) assert.equal(keyAction(ctx({ key })), null, key);
});
it('a bare Enter in a textarea stays a line break; Ctrl+Enter acts there', () => {
assert.equal(keyAction(ctx({ tag: 'TEXTAREA' })), null);
assert.equal(keyAction(ctx({ tag: 'TEXTAREA', ctrl: true })), 'saveNext');
assert.equal(keyAction(ctx({ tag: 'TEXTAREA', ctrl: true, repeatKey: 'enter' })), 'save');
});
it('a bare Enter on a button does nothing (the browser clicks it); Ctrl+Enter there does act', () => {
assert.equal(keyAction(ctx({ tag: 'BUTTON', isButton: true })), null);
assert.equal(keyAction(ctx({ key: 'Escape', tag: 'BUTTON', isButton: true })), 'cancel');
assert.equal(keyAction(ctx({ tag: 'BUTTON', isButton: true, ctrl: true })), 'saveNext');
assert.equal(keyAction(ctx({ tag: 'BUTTON', isButton: true, ctrl: true, popoverOpen: true })), null);
});
it('an open popover or dialog comes first: it closes, the row stays', () => {
for (const over of [{ popoverOpen: true }, { dialogOpen: true }]) {
assert.equal(keyAction(ctx({ key: 'Escape', ...over })), null);
assert.equal(keyAction(ctx({ key: 'Enter', ...over })), null);
assert.equal(keyAction(ctx({ key: 'Enter', ctrl: true, ...over })), null);
}
});
});
describe('renderTable: a table inside a drawer', () => {
it('a click in the inner table does not select a row of the outer one', () => {
let inner = null;
const { host } = mount({
rowExpansion: () => '<div class="inner"></div>',
onExpansionMount: (_r, el) => { inner = renderTable(el.querySelector('.inner'), { columns: COLUMNS(), rows: [{ id: 50, name: 'In', kind: 'z', amount: 0 }], onDelete() {} }); },
});
dom.click(row(host, 1).querySelectorAll('td')[1]);
assert.ok(inner);
dom.click(host.querySelector('tr[data-row-id="50"] td'));
assert.ok(host.querySelector('tr[data-row-id="50"]').classList.contains('row-selected'));
assert.ok(row(host, 1).classList.contains('row-selected'), 'the outer selection stays');
});
});