// 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: '' }, }); const tick = () => new Promise(r => setImmediate(r)); const ROWS = () => [ { id: 1, name: 'Bob', kind: 'a', amount: 3, note: 'first' }, { id: 2, name: '', 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 => `${r.amount ?? '-'}`; const { host } = mount({ columns: cols }); const cell = row(host, 2).querySelectorAll('td')[0]; assert.equal(cell.textContent, ''); 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 => `EUR ${r.amount}`; 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), ['', '']); 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 'sum'; } }); 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 => `

drawer ${r.id}

`, 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(''); } }); dom.click(host.querySelector('[data-save]')); await tick(); assert.ok(editRow(host)); assert.equal(host.querySelector('.edit-expand-row .form-error').textContent, ''); }); 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 => ``, 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: '' }, { 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 => `${r.name}
a`; const { host } = mount({ columns: cols, onDelete() {}, rowExpansion: r => `

${r.id}

` }); 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 ', searchLabel: 'Zoek "x"' }); assert.equal(host.querySelector('.tbl-count').title, 'Rijen'); assert.equal(host.querySelector('[data-tbl-group="1"]').getAttribute('aria-label'), 'Groeperen '); 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 => `

${r.id}

` }); 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: () => '

d

', 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 = 'Naam'; const { host } = mount({ columns: cols }); assert.equal(host.querySelector('th[data-sort="name"]').textContent, 'Naam'); }); }); 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: '' }, }); }); 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: '' }, }); }); }); 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: () => '
', 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'); }); });