Release v2.6.19

This commit is contained in:
2026-08-09 11:57:40 +01:00
parent 49c72923b4
commit 459f84ed94
34 changed files with 1650 additions and 256 deletions
+8 -8
View File
@@ -17,7 +17,7 @@ function createHandlers() {
return { app, handlers };
}
test('clients list defaults to client then ip ordering', async () => {
test('clients list defaults to client ordering', async () => {
const { app, handlers } = createHandlers();
registerClientsRoutes(app, {
@@ -38,7 +38,7 @@ test('clients list defaults to client then ip ordering', async () => {
return JSON.stringify(data.clients.map(function (client) {
return {
client_name: client.client_name,
clientIp: client.clientIp
clientId: client.clientId
};
}));
}
@@ -46,9 +46,9 @@ test('clients list defaults to client then ip ordering', async () => {
buildDashboardState: async () => ({
screens: [],
clients: [
{ client_name: 'Beta', clientIp: '10.0.0.9' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
{ client_name: 'Alpha', clientIp: '10.0.0.2' }
{ client_name: 'Beta', clientId: 'beta-1' },
{ client_name: 'Alpha', clientId: 'alpha-1' },
{ client_name: 'Alpha', clientId: 'alpha-2' }
]
}),
requirePermission() {
@@ -76,9 +76,9 @@ test('clients list defaults to client then ip ordering', async () => {
}, response, () => {});
assert.deepEqual(JSON.parse(response.body), [
{ client_name: 'Alpha', clientIp: '10.0.0.2' },
{ client_name: 'Alpha', clientIp: '10.0.0.20' },
{ client_name: 'Beta', clientIp: '10.0.0.9' }
{ client_name: 'Alpha', clientId: 'alpha-1' },
{ client_name: 'Alpha', clientId: 'alpha-2' },
{ client_name: 'Beta', clientId: 'beta-1' }
]);
});
+43 -21
View File
@@ -97,26 +97,9 @@ function createRow(startValue, endValue) {
return row;
}
function formatDateTimeLocalValue(date) {
function pad(value) {
return String(value).padStart(2, '0');
}
return [
String(date.getFullYear()).padStart(4, '0'),
'-',
pad(date.getMonth() + 1),
'-',
pad(date.getDate()),
'T',
pad(date.getHours()),
':',
pad(date.getMinutes())
].join('');
}
test('timetable group form highlights an end time that is too early', () => {
const row = createRow('2026-08-07T10:00', '2026-08-07T10:00');
const row = createRow('2026-08-07T12:00', '2026-08-07T12:00');
const timezoneInput = createInput('timezone', 'Europe/Berlin');
const body = {
rows: [row],
querySelectorAll(selector) {
@@ -140,7 +123,10 @@ test('timetable group form highlights an end time that is too early', () => {
};
const form = {
dataset: {},
addEventListener() {}
listeners: Object.create(null),
addEventListener(type, handler) {
this.listeners[type] = handler;
}
};
const templateRow = createRow('', '');
const template = {
@@ -161,6 +147,9 @@ test('timetable group form highlights an end time that is too early', () => {
if (id === 'timetable-group-form') {
return form;
}
if (id === 'timetable-group-timezone') {
return timezoneInput;
}
if (id === 'timetable-entry-row-template') {
return template;
}
@@ -194,14 +183,47 @@ test('timetable group form highlights an end time that is too early', () => {
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(row.startInput.value, '2026-08-07T12:00');
assert.equal(row.endInput.value, '2026-08-07T12:00');
assert.equal(row.endInput.validityMessage, 'End time must be at least 1 minute after the start time.');
assert.equal(row.endInput.classList.contains('is-invalid'), true);
assert.equal(row.endInput.attributes['aria-invalid'], 'true');
row.endInput.value = formatDateTimeLocalValue(new Date(new Date(row.startInput.value).getTime() + 60000));
row.endInput.value = '2026-08-07T12:01';
row.endInput.dispatchEvent({ type: 'input' });
assert.equal(row.endInput.validityMessage, '');
assert.equal(row.endInput.classList.contains('is-invalid'), false);
assert.equal(Object.prototype.hasOwnProperty.call(row.endInput.attributes, 'aria-invalid'), false);
timezoneInput.value = 'Europe/London';
timezoneInput.dispatchEvent({ type: 'change' });
assert.equal(row.startInput.value, '2026-08-07T12:00');
assert.equal(row.endInput.value, '2026-08-07T12:01');
assert.equal(form.dataset.timetableTimezone, 'Europe/London');
assert.equal(form.dataset.dirty, 'true');
const formData = {
deleted: [],
appended: [],
delete(name) {
this.deleted.push(name);
},
append(name, value) {
this.appended.push([name, value]);
}
};
form.listeners.formdata({ formData: formData });
const startEntry = formData.appended.find(function (item) {
return item[0] === 'entry_start_datetime[]';
});
const endEntry = formData.appended.find(function (item) {
return item[0] === 'entry_end_datetime[]';
});
assert.equal(startEntry[1], '2026-08-07T11:00:00.000Z');
assert.equal(endEntry[1], '2026-08-07T11:01:00.000Z');
});
+295
View File
@@ -0,0 +1,295 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function loadScheduleModule(overrides) {
const timeDatePlaceholdersScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/time-date-placeholders.js'), 'utf8');
const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8');
const scriptPath = require.resolve('../src/web/public/js/regions/type/schedule.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const registry = new Map();
const customWindow = overrides && overrides.window ? overrides.window : {};
const sandbox = {
document: {
addEventListener() {}
},
window: {
pulseRegionTypes: {
register(type, module) {
registry.set(type, module);
}
},
pulseRegionUtils: {
escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
sanitizeRichText(value) {
return String(value === undefined || value === null ? '' : value);
}
},
placeholderChips: {
renderChips(tokens) {
return tokens.map((token) => '<span class="chip">{{' + token + '}}</span>').join(' ');
}
},
initialData: {
timetableGroups: [
{
id: 1,
name: 'Main board',
timezone: 'UTC',
entries: [
{
id: 101,
title: 'Launch',
short_description: 'Doors open',
start_datetime: '2026-08-09T10:00:00.000Z',
end_datetime: '2026-08-09T11:00:00.000Z'
}
]
}
]
},
Intl: Intl,
Date: Date,
Object: Object,
Array: Array,
Number: Number,
String: String,
Boolean: Boolean,
Math: Math,
JSON: JSON,
RegExp: RegExp,
console: console
},
...customWindow
};
sandbox.window = Object.assign({}, sandbox.window, customWindow);
vm.runInNewContext(timeDatePlaceholdersScript, sandbox, { filename: 'time-date-placeholders.js' });
vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' });
if (customWindow.placeholderUtils) {
sandbox.window.placeholderUtils = customWindow.placeholderUtils;
}
vm.runInNewContext(script, sandbox, { filename: scriptPath });
return registry.get('timetable');
}
test('timetable region editor lists timezone transforms in helper text', () => {
const module = loadScheduleModule();
const html = module.renderEditorCard({
region: { id: 47, label: 'Timetable' },
current: {
value: '<p>{{title}} {{start.tz()}} {{start.tz_short()}}</p>',
timetable_group_id: 1,
display_mode: 'current',
max_items: 3
},
timetableGroups: [
{
id: 1,
name: 'Main board',
timezone: 'UTC',
entries: []
}
]
});
assert.match(html, /Placeholder values support transforms/);
assert.match(html, /<code>\{\{start\.tz\(\)\}\}<\/code>/);
assert.match(html, /<code>\{\{start\.tz_short\(\)\}\}<\/code>/);
assert.doesNotMatch(html, /class="chip">\{\{start\.tz\(\)\}\}<\/span>/);
});
test('timetable region editor shows supported date format tokens', () => {
const module = loadScheduleModule();
const html = module.renderEditorCard({
region: { id: 47, label: 'Timetable' },
current: {
value: '<p>{{start.format("MMM D, YYYY h:mm A")}}</p>',
timetable_group_id: 1,
display_mode: 'current',
max_items: 3
},
timetableGroups: [
{
id: 1,
name: 'Main board',
timezone: 'UTC',
entries: []
}
]
});
assert.match(html, /Supported date format tokens/);
assert.match(html, /<th scope="col">Format<\/th>/);
assert.ok(html.indexOf('<td><code>D</code></td><td>1-31</td><td>The day of the month</td>') < html.indexOf('<td><code>ddd</code></td><td>Mon</td><td>The abbreviated weekday name</td>'));
assert.ok(html.indexOf('<td><code>ddd</code></td><td>Mon</td><td>The abbreviated weekday name</td>') < html.indexOf('<td><code>M</code></td><td>1-12</td><td>The month, beginning at 1</td>'));
assert.ok(html.indexOf('<td><code>YY</code></td><td>18</td><td>The two-digit year</td>') < html.indexOf('<td><code>YYYY</code></td><td>2018</td><td>The four-digit year</td>'));
assert.doesNotMatch(html, /<td><code>y<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>yyyy<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>yy<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>tz<\/code><\/td>/);
assert.doesNotMatch(html, /<td><code>tz_short<\/code><\/td>/);
assert.match(html, /Use these tokens inside <code>\.format\(\.\.\.\)<\/code>/);
});
test('timetable region preview resolves timezone placeholders from explicit timezone transforms', () => {
const module = loadScheduleModule();
const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 },
{
value: '<p>{{start.tz("UTC")}} {{start.tz_short("UTC")}}</p>',
timetable_group_id: 1,
display_mode: 'both',
max_items: 3
},
{
timetableGroups: [
{
id: 1,
name: 'Main board',
timezone: 'UTC',
entries: [
{
id: 101,
title: 'Launch',
short_description: 'Doors open',
start_datetime: '2026-08-10T10:00:00.000Z',
end_datetime: '2026-08-10T11:00:00.000Z'
}
]
}
]
}
);
assert.match(preview, /UTC/);
});
test('timetable placeholder format transform respects uppercase and lowercase day period tokens', () => {
const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8');
const sandbox = {
window: {},
Intl: Intl,
Date: Date,
Object: Object,
Array: Array,
Number: Number,
String: String,
Boolean: Boolean,
Math: Math,
JSON: JSON,
RegExp: RegExp,
console: console
};
vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' });
const upper = sandbox.window.placeholderUtils.resolvePlaceholderExpression(
{ start: '2026-08-09T13:05:00.000Z' },
'start.format("MMM D, YYYY h:mm A")'
);
const lower = sandbox.window.placeholderUtils.resolvePlaceholderExpression(
{ start: '2026-08-09T13:05:00.000Z' },
'start.format("MMM D, YYYY h:mm a")'
);
assert.match(String(upper), /PM$/);
assert.match(String(lower), /pm$/);
});
test('timetable timezone abbreviation changes across daylight saving boundaries', () => {
const module = loadScheduleModule();
const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 },
{
value: '<p>{{start.tz_short("Europe/London")}}</p>',
timetable_group_id: 1,
display_mode: 'both',
max_items: 10
},
{
timetableGroups: [
{
id: 1,
name: 'London board',
timezone: 'Europe/London',
entries: [
{
id: 201,
title: 'Before DST ends',
short_description: '',
start_datetime: '2026-10-24T12:00:00.000Z',
end_datetime: '2026-10-24T13:00:00.000Z'
},
{
id: 202,
title: 'After DST ends',
short_description: '',
start_datetime: '2026-10-26T12:00:00.000Z',
end_datetime: '2026-10-26T13:00:00.000Z'
}
]
}
]
}
);
assert.match(preview, /BST/);
assert.match(preview, /(GMT|UTC)/);
});
test('timetable preview does not force the group timezone into placeholder transforms', () => {
const calls = [];
const module = loadScheduleModule({
window: {
placeholderUtils: {
resolvePlaceholderExpression(value, expression, options) {
calls.push({ expression, options: options || {} });
return options && options.timeZone ? options.timeZone : 'local';
},
formatPlaceholderValue(value) {
return String(value === undefined || value === null ? '' : value);
}
}
}
});
const preview = module.renderPreview(
{ id: 47, width: 500, height: 280 },
{
value: '<p>{{start.tz_short()}}</p>',
timetable_group_id: 1,
display_mode: 'both',
max_items: 3
},
{
timetableGroups: [
{
id: 1,
name: 'Madrid board',
timezone: 'Europe/Madrid',
entries: [
{
id: 101,
title: 'Launch',
short_description: 'Doors open',
start_datetime: '2026-08-10T10:00:00.000Z',
end_datetime: '2026-08-10T11:00:00.000Z'
}
]
}
]
}
);
assert.match(preview, /local/);
assert.equal(calls.length > 0 ? Boolean(calls[0].options && calls[0].options.timeZone) : false, false);
});
+42
View File
@@ -0,0 +1,42 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildSlidePayload } = require('../src/data/slides');
test('buildSlidePayload normalizes timetable region fields', async () => {
const pool = {
async query(sql) {
if (sql.includes('FROM c_templates st')) {
return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]];
}
if (sql.includes('FROM c_template_regions')) {
return [[{ id: 47, template_id: 9, region_key: 'timetable', region_type: 'timetable', label: 'Timetable' }]];
}
return [[]];
}
};
const payload = await buildSlidePayload(pool, {
body: {
title: 'Timetable slide',
template_id: '9',
region_text_47: '<p>{{title}}</p>',
region_timetable_group_id_47: '1',
region_timetable_display_mode_47: 'current',
region_timetable_max_items_47: '7'
},
files: []
}, null);
const content = JSON.parse(payload.contentJson);
assert.equal(content.timetable.type, 'timetable');
assert.equal(content.timetable.text, '<p>{{title}}</p>');
assert.equal(content.timetable.value, '<p>{{title}}</p>');
assert.equal(content.timetable.timetable_group_id, '1');
assert.equal(content.timetable.display_mode, 'current');
assert.equal(content.timetable.max_items, '7');
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false);
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
});
+5 -2
View File
@@ -14,7 +14,8 @@ test('timetable duplicate helper copies group and entry fields', () => {
const group = {
id: 5,
name: 'Morning Shift',
short_description: 'Weekday events'
short_description: 'Weekday events',
timezone: 'Europe/Berlin'
};
const entry = {
id: 12,
@@ -32,6 +33,7 @@ test('timetable duplicate helper copies group and entry fields', () => {
assert.equal(duplicateGroup.name, 'Copy of Morning Shift');
assert.equal(duplicateGroup.short_description, 'Weekday events');
assert.equal(duplicateGroup.shortDescription, 'Weekday events');
assert.equal(duplicateGroup.timezone, 'Europe/Berlin');
assert.equal(duplicateEntry.id, null);
assert.equal(duplicateEntry.schedule_group_id, null);
assert.equal(duplicateEntry.title, 'Opening');
@@ -42,7 +44,7 @@ test('timetable duplicate helper copies group and entry fields', () => {
test('timetable duplicate form model keeps info toast variant and secondary actions', () => {
const model = buildTimetableGroupFormViewModel(
{ id: null, name: 'Morning Shift', shortDescription: 'Weekday events' },
{ id: null, name: 'Morning Shift', shortDescription: 'Weekday events', timezone: 'Europe/Berlin' },
[],
'Review the copied values and save when ready.',
{ permissionKeys: ['timetables.create'] },
@@ -52,6 +54,7 @@ test('timetable duplicate form model keeps info toast variant and secondary acti
assert.equal(model.messageVariant, 'info');
assert.equal(model.showSaveSecondaryActions, true);
assert.equal(model.timetableGroup.timezone, 'Europe/Berlin');
});
test('timetable list template includes duplicate action', () => {
+155
View File
@@ -0,0 +1,155 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerTimetableRoutes = require('../src/web/routes/data-sources/timetables/routes');
function createApp() {
const handlers = {};
return {
handlers: handlers,
app: {
get(path, ...routeHandlers) {
handlers['GET ' + path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers['POST ' + path] = routeHandlers;
}
}
};
}
function createConnection() {
const queries = [];
return {
queries: queries,
async beginTransaction() {
queries.push(['beginTransaction']);
},
async query(sql, params) {
queries.push([sql, params]);
if (String(sql).indexOf('INSERT INTO i_timetable_groups') !== -1) {
return [{ insertId: 42 }];
}
return [{}];
},
async commit() {
queries.push(['commit']);
},
async rollback() {
queries.push(['rollback']);
},
release() {
queries.push(['release']);
}
};
}
test('timetable create and update persist the selected timezone', async () => {
const connection = createConnection();
const { app, handlers } = createApp();
registerTimetableRoutes(app, {
pool: {
getConnection() {
return connection;
}
},
common: {
fetchTimetableGroupById: async function () {
return {
id: 7,
name: 'Existing',
short_description: 'Old description',
timezone: 'Europe/London'
};
},
fetchDuplicateName: async function () {
return false;
},
buildTimetableGroupPayload: function (req) {
return {
name: req.body.name,
shortDescription: req.body.short_description,
timezone: req.body.timezone
};
},
fetchTimetableEntriesByGroupId: async function () {
return [];
}
},
getAuditUserId: function () {
return 9;
},
redirectAfterSave: function (_req, res, url) {
res.redirect(url);
},
parseDateTimeLocal: function (value) {
return value ? new Date(value) : null;
},
requirePermission: function () {
return function (_req, _res, next) {
next();
};
}
});
const createRoute = handlers['POST /data-sources/timetables'][1];
const updateRoute = handlers['POST /data-sources/timetables/:id'][1];
const createResponse = {
redirects: [],
redirect(url) {
this.redirects.push(url);
},
status() {
return this;
}
};
await createRoute({
body: {
name: 'Morning',
short_description: 'Breakfast block',
timezone: 'Europe/Berlin'
},
currentUser: { id: 1 },
query: {}
}, createResponse, function () {});
assert.deepEqual(connection.queries[1], [
'INSERT INTO i_timetable_groups (name, short_description, timezone, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
['Morning', 'Breakfast block', 'Europe/Berlin', 9, 9]
]);
assert.equal(createResponse.redirects[0], '/data-sources/timetables/42/edit');
connection.queries.length = 0;
const updateResponse = {
redirects: [],
redirect(url) {
this.redirects.push(url);
},
status() {
return this;
}
};
await updateRoute({
params: { id: '7' },
body: {
name: 'Morning',
short_description: 'Breakfast block',
timezone: 'Europe/Berlin'
},
currentUser: { id: 1 },
query: {}
}, updateResponse, function () {});
assert.deepEqual(connection.queries[1], [
'UPDATE i_timetable_groups SET name = ?, short_description = ?, timezone = ?, modified_by = ? WHERE id = ?',
['Morning', 'Breakfast block', 'Europe/Berlin', 9, 7]
]);
assert.equal(updateResponse.redirects[0], '/data-sources/timetables/7/edit');
});