Add canvas size disable controls
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m35s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 59s

This commit is contained in:
lzstealth committed 2026-10-15 02:09:34 +01:00
1 parent 68e1f80190
commit f0601f823d
22 files changed
+205 -87

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
## 2.15.3 - 2026-10-15
### Changed
- Replaced the canvas-size duplicate action with reversible disable/enable controls. Disabled sizes remain assigned to existing templates and playlists but cannot be selected for new ones.
## 2.15.2 - 2026-10-15
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
"version": "2.15.2",
"version": "2.15.3",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
"version": "2.15.2",
"version": "2.15.3",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "2.15.2",
"version": "2.15.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.15.2",
"version": "2.15.3",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.15.2",
"version": "2.15.3",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
+2 -2
View File
@@ -4,7 +4,7 @@ const { fetchPagedRows } = require('#src/data/utils');
async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
@@ -141,7 +141,7 @@ async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sor
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
selectSql: `SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by,
(SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count
FROM c_canvas_sizes
ORDER BY width ASC, height ASC, name ASC`,
+7 -4
View File
@@ -3,14 +3,17 @@
const { fetchPagedRows } = require('#src/data/utils');
const MAX_CANVAS_SIZE_DIMENSION = 16384;
async function fetchCanvasSizesData(pool) {
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
async function fetchCanvasSizesData(pool, includeDisabledId) {
const disabledId = Number(includeDisabledId);
const [canvasSizes] = disabledId > 0
? await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE enabled = 1 OR id = ? ORDER BY width ASC, height ASC, name ASC', [disabledId])
: await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE enabled = 1 ORDER BY width ASC, height ASC, name ASC');
return { canvasSizes };
}
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC',
selectSql: 'SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC',
countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
@@ -30,7 +33,7 @@ async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, s
}
async function fetchCanvasSizeById(pool, id) {
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
const [rows] = await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
return rows[0] || null;
}
+6 -1
View File
@@ -264,13 +264,18 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let resolvedCanvasSizeId = canvasSizeId;
if (resolvedCanvasSizeId) {
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const [canvasSizes] = await pool.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const canvasSize = canvasSizes[0];
if (!canvasSize) {
const error = new Error('Canvas size not found.');
error.statusCode = 400;
throw error;
}
if ((canvasSize.enabled === 0 || canvasSize.enabled === false) && (!existingTemplate || Number(existingTemplate.canvas_size_id) !== Number(canvasSize.id))) {
const error = new Error('Canvas size is disabled.');
error.statusCode = 400;
throw error;
}
canvasWidth = Number(canvasSize.width);
canvasHeight = Number(canvasSize.height);
} else {
+1
View File
@@ -29,6 +29,7 @@ async function ensureSchema(pool, options) {
name VARCHAR(255) NOT NULL,
width INT NOT NULL,
height INT NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+7
View File
@@ -677,6 +677,13 @@ const VERSIONED_MIGRATIONS = [
await ensureColumn(pool, 'c_playlist_slides', 'group_mode', "VARCHAR(16) NOT NULL DEFAULT 'select'", 'group_shuffle');
await ensureColumn(pool, 'c_playlist_slides', 'group_repeat_count', 'INT NOT NULL DEFAULT 1', 'group_mode');
}
},
{
version: '2.15.3',
label: 'v2.15.3 canvas size enable state',
run: async function (pool) {
await ensureColumn(pool, 'c_canvas_sizes', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'height');
}
}
];
+21 -23
View File
@@ -36,7 +36,6 @@ module.exports = function registerContentRoutes(app, deps) {
const hasAnyPermission = deps.hasAnyPermission;
const { buildPagination } = require('#src/web/lib/pagination');
const { buildDuplicateSlideName, buildDuplicateSlide } = require('#src/web/routes/signage/slides/duplicate');
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
const LIST_PAGE_SIZE = 25;
const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
@@ -337,7 +336,11 @@ module.exports = function registerContentRoutes(app, deps) {
duplicateIndex += 1;
}
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser, null, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES));
const duplicateTemplate = buildDuplicateTemplate(template, duplicateName);
if (!canvasSizes.canvasSizes.some((size) => Number(size.id) === Number(duplicateTemplate.canvas_size_id))) {
duplicateTemplate.canvas_size_id = null;
}
res.send(pages.renderTemplateAddPage(duplicateTemplate, req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser, null, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES));
} catch (error) {
next(error);
}
@@ -749,7 +752,7 @@ module.exports = function registerContentRoutes(app, deps) {
}
template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
const sizeData = await common.fetchCanvasSizesData(pool);
const sizeData = await common.fetchCanvasSizesData(pool, template.canvas_size_id);
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = await fetchMediaAssets(pool);
const settings = await fetchAppSettings(pool);
@@ -882,26 +885,6 @@ module.exports = function registerContentRoutes(app, deps) {
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/canvas-sizes/:id/duplicate', requirePermission('canvas-sizes.read'), requirePermission('canvas-sizes.create'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
let duplicateName = buildDuplicateCanvasSizeName(canvasSize.name);
let duplicateIndex = 2;
while (await common.fetchDuplicateName(pool, 'c_canvas_sizes', duplicateName)) {
duplicateName = buildDuplicateCanvasSizeName(canvasSize.name) + ' (' + duplicateIndex + ')';
duplicateIndex += 1;
}
res.send(pages.renderCanvasSizeAddPage(buildDuplicateCanvasSize(canvasSize, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
try {
const payload = common.buildCanvasSizePayload(req, null);
@@ -975,6 +958,21 @@ module.exports = function registerContentRoutes(app, deps) {
}
});
app.post('/canvas-sizes/:id/toggle', requirePermission('canvas-sizes.update'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
if (!canvasSize) {
return res.status(404).send('Canvas size not found');
}
const enabled = canvasSize.enabled === 0 || canvasSize.enabled === false ? 1 : 0;
await pool.query('UPDATE c_canvas_sizes SET enabled = ?, modified_by = ? WHERE id = ?', [enabled, getAuditUserId(req), canvasSize.id]);
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'canvas-sizes', eventType: enabled ? 'canvas-size.enabled' : 'canvas-size.disabled', actorUserId: getAuditUserId(req), targetType: 'canvas-size', targetId: canvasSize.id, targetLabel: canvasSize.name });
res.redirect('/canvas-sizes?message=' + encodeURIComponent(enabled ? 'Canvas size enabled.' : 'Canvas size disabled.'));
} catch (error) {
next(error);
}
});
app.post('/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
+6
View File
@@ -134,6 +134,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
throw createValidationError('Canvas size not found.');
}
requestedCanvasId = Number(canvasSize.id);
if ((canvasSize.enabled === 0 || canvasSize.enabled === false) && (!shouldUpdatePlaylist || Number(playlist.canvas_id) !== requestedCanvasId)) {
throw createValidationError('Canvas size is disabled.');
}
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
throw new Error('Canvas size cannot be changed after the playlist is created.');
}
@@ -387,6 +390,9 @@ module.exports = function registerPlaylistRoutes(app, deps) {
if (!canvasSize) {
return res.status(400).send('Canvas size not found.');
}
if (canvasSize.enabled === 0 || canvasSize.enabled === false) {
return res.status(400).send('Canvas size is disabled.');
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, canvasSize.id, actorId, actorId]);
@@ -1,21 +0,0 @@
// Canvas size duplicate helpers.
function buildDuplicateCanvasSizeName(name) {
const source = String(name || 'Canvas size').trim() || 'Canvas size';
return 'Copy of ' + source;
}
function buildDuplicateCanvasSize(canvasSize, duplicateName) {
const source = canvasSize || {};
return {
id: null,
name: String(duplicateName || buildDuplicateCanvasSizeName(source.name)).trim(),
width: Math.max(1, Number(source.width || 1920)),
height: Math.max(1, Number(source.height || 1080))
};
}
module.exports = {
buildDuplicateCanvasSizeName,
buildDuplicateCanvasSize
};
@@ -8,6 +8,7 @@ module.exports = function renderCanvasSizesPage(data, message, currentUser) {
name: size.name,
width: size.width,
height: size.height,
enabled: size.enabled !== 0 && size.enabled !== false,
templateCount: size.template_count !== undefined
? Number(size.template_count) || 0
: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length
@@ -228,8 +228,11 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
const computedPlaylistCanvasId = playlistSlideCanvasIds.length === 1 ? playlistSlideCanvasIds[0] : null;
const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId;
const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null;
const canvasSizes = (data.canvasSizes || []).map((size) => Object.assign({}, size, {
label: formatCanvasSizeLabel(size),
const canvasSizes = (data.canvasSizes || []).filter((size) => {
const enabled = size.enabled !== 0 && size.enabled !== false;
return enabled || (playlistCanvasId && Number(size.id) === playlistCanvasId);
}).map((size) => Object.assign({}, size, {
label: formatCanvasSizeLabel(size) + (size.enabled === 0 || size.enabled === false ? ' (Disabled)' : ''),
canvasId: Number(size.id) || null,
selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id))
}));
+8 -4
View File
@@ -24,6 +24,7 @@
<tr>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="dimensions">Dimensions</th>
<th>Status</th>
<th data-table-sort-key="templates">Templates</th>
<th>Actions</th>
</tr>
@@ -34,15 +35,18 @@
<tr data-table-search-row>
<td data-label="Name">{{name}}</td>
<td data-label="Dimensions">{{width}}x{{height}}</td>
<td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}secondary{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
<td data-label="Templates"><span class="chip">{{templateCount}} template{{#if (gt templateCount 1)}}s{{else}}{{/if}}{{#if (lt templateCount 1)}}s{{/if}}</span></td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'canvas-sizes.create' 'canvas-sizes.update' 'canvas-sizes.delete')}}
{{#if (anyPermission ../currentUser 'canvas-sizes.update' 'canvas-sizes.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'canvas-sizes.update')}}
<a class="btn btn-sm btn-primary" href="/canvas-sizes/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'canvas-sizes.create')}}
<a class="btn btn-sm btn-secondary" href="/canvas-sizes/{{id}}/duplicate">Dupe</a>
{{#if (hasPermission ../currentUser 'canvas-sizes.update')}}
<form class="inline-form" method="post" action="/canvas-sizes/{{id}}/toggle" data-confirm-message="{{#if enabled}}Disable{{else}}Enable{{/if}} this canvas size?">
<button class="btn btn-sm btn-secondary" type="submit">{{#if enabled}}Disable{{else}}Enable{{/if}}</button>
</form>
{{/if}}
{{#if (hasPermission ../currentUser 'canvas-sizes.delete')}}
<form class="inline-form" method="post" action="/canvas-sizes/{{id}}/delete" data-confirm-message="Delete this canvas size?">
@@ -61,7 +65,7 @@
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="4" class="empty">No canvas sizes yet.</td></tr>
<tr data-table-search-empty-default><td colspan="5" class="empty">No canvas sizes yet.</td></tr>
{{/if}}
</tbody>
</table>
+1 -1
View File
@@ -82,7 +82,7 @@
{{/unless}}
{{#if canvasSizes.length}}
{{#each canvasSizes}}
<option value="{{id}}" data-width="{{width}}" data-height="{{height}}" {{#if (eq ../template.canvas_size_id id)}}selected{{/if}}>{{name}} ({{width}}x{{height}})</option>
<option value="{{id}}" data-width="{{width}}" data-height="{{height}}" {{#if (eq ../template.canvas_size_id id)}}selected{{/if}}>{{name}} ({{width}}x{{height}}){{#unless enabled}} (Disabled){{/unless}}</option>
{{/each}}
{{/if}}
</select>
+57 -20
View File
@@ -7,9 +7,10 @@ const path = require('node:path');
require('../src/common');
const { buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('../src/data');
const { fetchCanvasSizesData } = require('../src/data/canvas-sizes');
const { buildCanvasSizeFormViewModel } = require('../src/web/routes/signage/canvas-sizes/form-view-model');
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('../src/web/routes/signage/canvas-sizes/duplicate');
const registerContentRoutes = require('../src/web/routes/admin/content');
const renderCanvasSizesPage = require('../src/web/routes/signage/canvas-sizes/list');
const adminPageScript = fs.readFileSync(require.resolve('../src/web/public/js/admin/admin-page.js'), 'utf8');
const canvasSizeFormTemplate = fs.readFileSync(require.resolve('../src/web/views/signage/canvas-sizes/form.hbs'), 'utf8');
const canvasSizeListTemplate = fs.readFileSync(require.resolve('../src/web/views/signage/canvas-sizes/list.hbs'), 'utf8');
@@ -40,23 +41,40 @@ test('canvas size edit form locks dimensions while the size is in use', () => {
assert.ok(canvasSizeFormTemplate.includes('data-limit-input-value'));
});
test('buildDuplicateCanvasSize copies canvas size fields for the create page', () => {
const duplicate = buildDuplicateCanvasSize({
id: 7,
name: 'Full HD',
width: 1920,
height: 1080
}, buildDuplicateCanvasSizeName('Full HD'));
assert.equal(duplicate.id, null);
assert.equal(duplicate.name, 'Copy of Full HD');
assert.equal(duplicate.width, 1920);
assert.equal(duplicate.height, 1080);
test('canvas size list replaces duplicate action with status and toggle controls', () => {
assert.doesNotMatch(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/duplicate/);
assert.match(canvasSizeListTemplate, /data-label="Status"/);
assert.match(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/toggle/);
assert.match(canvasSizeListTemplate, />\{\{#if enabled\}\}Disable\{\{else\}\}Enable\{\{\/if\}\}<\/button>/);
});
test('canvas size list template includes duplicate action', () => {
assert.match(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/duplicate/);
assert.match(canvasSizeListTemplate, />Dupe<\/a>/);
test('canvas size list renderer preserves enabled state for status labels', () => {
const html = renderCanvasSizesPage({
canvasSizes: [
{ id: 1, name: 'Enabled size', width: 1920, height: 1080, enabled: 1, template_count: 0 },
{ id: 2, name: 'Disabled size', width: 1280, height: 720, enabled: 0, template_count: 0 }
]
}, '', null);
assert.match(html, /data-label="Status"><span class="badge text-bg-success">Enabled<\/span>/);
assert.match(html, /data-label="Status"><span class="badge text-bg-secondary">Disabled<\/span>/);
});
test('canvas-size selectors exclude disabled sizes unless retaining the current assignment', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push([sql, params]);
return [[]];
}
};
await fetchCanvasSizesData(pool);
await fetchCanvasSizesData(pool, 9);
assert.match(queries[0][0], /WHERE enabled = 1/);
assert.match(queries[1][0], /WHERE enabled = 1 OR id = \?/);
assert.deepEqual(queries[1][1], [9]);
});
test('canvas size update returns a warning when an in-use canvas changes dimensions', async () => {
@@ -71,11 +89,19 @@ test('canvas size update returns a warning when an in-use canvas changes dimensi
};
const queries = [];
const toggleUpdates = [];
const auditEvents = [];
let enabled = 1;
const pool = {
async query(sql) {
queries.push(sql);
async query(sql, params) {
queries.push([sql, params]);
if (sql.includes('UPDATE c_canvas_sizes SET enabled')) {
enabled = params[0];
toggleUpdates.push(params);
return [{ affectedRows: 1 }];
}
if (sql.includes('FROM c_canvas_sizes WHERE id = ?')) {
return [[{ id: 7, name: 'Full HD', width: 1920, height: 1080 }]];
return [[{ id: 7, name: 'Full HD', width: 1920, height: 1080, enabled: enabled }]];
}
if (sql.includes('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?')) {
return [[{ ref_count: 1 }]];
@@ -92,7 +118,7 @@ test('canvas size update returns a warning when an in-use canvas changes dimensi
getSortQuery: () => '',
getSortDirectionQuery: () => 'asc',
fetchCanvasSizeById: async (connection, id) => {
const [rows] = await connection.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
const [rows] = await connection.query('SELECT id, name, width, height, enabled, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
return rows[0] || null;
},
buildCanvasSizePayload: buildCanvasSizePayload,
@@ -124,6 +150,7 @@ test('canvas size update returns a warning when an in-use canvas changes dimensi
removeUnusedUploadFiles: async () => {},
syncPlaylistUploadsOnChange: async () => {},
getAuditUserId: () => 1,
recordRequestAuditEvent: async (_connection, _req, event) => auditEvents.push(event),
redirectAfterSave: () => {},
notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {},
@@ -172,6 +199,16 @@ test('canvas size update returns a warning when an in-use canvas changes dimensi
assert.equal(res.body, 'This canvas size is still used by one or more templates, so its dimensions cannot be changed.');
assert.equal(res.redirectedTo, '');
assert.equal(queries.length, 2);
const toggleHandler = handlers['/canvas-sizes/:id/toggle'][1];
const toggleReq = { params: { id: '7' } };
const toggleRes = { redirectedTo: '', redirect(url) { this.redirectedTo = url; } };
await toggleHandler(toggleReq, toggleRes, (error) => { throw error; });
assert.equal(toggleRes.redirectedTo, '/canvas-sizes?message=Canvas%20size%20disabled.');
await toggleHandler(toggleReq, toggleRes, (error) => { throw error; });
assert.deepEqual(toggleUpdates.map((params) => params[0]), [0, 1]);
assert.deepEqual(auditEvents.map((event) => event.eventType), ['canvas-size.disabled', 'canvas-size.enabled']);
});
test('slide upload cleanup route removes pending media assets', async () => {
+16
View File
@@ -84,3 +84,19 @@ test('playlist rows use the longest video region duration', () => {
assert.equal(model.playlistSlides[0].videoDurationSeconds, 27.75);
assert.equal(model.playlistSlides[0].durationSeconds, 27.75);
});
test('disabled canvas sizes are hidden for new playlists but retained for existing assignments', () => {
const canvasSizes = [
{ id: 1, name: 'Full HD', width: 1920, height: 1080, enabled: 1 },
{ id: 2, name: 'Legacy', width: 1280, height: 720, enabled: 0 }
];
const data = { playlistSlides: [], slides: [], canvasSizes: canvasSizes };
const createModel = buildPlaylistFormViewModel({}, data, '', null, {});
assert.deepEqual(createModel.canvasSizes.map((size) => size.id), [1]);
const editModel = buildPlaylistFormViewModel({ id: 12, canvas_id: 2 }, data, '', null, { isEdit: true });
assert.deepEqual(editModel.canvasSizes.map((size) => size.id), [1, 2]);
assert.match(editModel.canvasSizes[1].label, /Disabled/);
assert.equal(editModel.canvasSizes[1].selected, true);
});
+20 -1
View File
@@ -38,6 +38,7 @@ function createResponse() {
test('playlist create and update redirect to the canonical edit path', async () => {
const calls = [];
let groupInsertParams = null;
let canvasSizeEnabled = 1;
const connection = {
async beginTransaction() {},
async commit() {},
@@ -76,7 +77,7 @@ test('playlist create and update redirect to the canonical edit path', async ()
},
common: {
async fetchDuplicateName() { return null; },
async fetchCanvasSizeById() { return { id: 7 }; },
async fetchCanvasSizeById() { return { id: 7, enabled: canvasSizeEnabled }; },
async fetchPlaylistById() { return { id: 4, canvas_id: 7 }; }
},
pages: {},
@@ -161,6 +162,24 @@ test('playlist create and update redirect to the canonical edit path', async ()
'insert-slide',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
]);
canvasSizeEnabled = 0;
const disabledCreateRes = createResponse();
await createHandlers[1]({
path: '/playlists',
body: { name: 'Disabled size playlist', canvas_size_id: '7', slide_id: [] }
}, disabledCreateRes, (error) => { throw error; });
assert.equal(disabledCreateRes.statusCode, 400);
assert.equal(disabledCreateRes.body, 'Canvas size is disabled.');
const retainDisabledRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4',
params: { id: '4' },
body: { name: 'Lunch Loop', canvas_size_id: '7', slide_id: [] }
}, retainDisabledRes, (error) => { throw error; });
assert.equal(retainDisabledRes.redirectedTo, '/playlists/4/edit');
for (const pickCount of ['0', '2']) {
const invalidRes = createResponse();
await updateHandlers[1]({
+12 -3
View File
@@ -61,13 +61,22 @@ test('playlist shuffle migration upgrades an existing 2.15.0 installation', asyn
test('playlist repeat migration upgrades an existing 2.15.1 installation', async () => {
const pool = createPool([]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.1' });
assert.equal(pending.length, 1);
assert.equal(pending[0].version, '2.15.2');
await pending[0].run(pool);
const repeatMigration = pending.find(migration => migration.version === '2.15.2');
assert.ok(repeatMigration);
await repeatMigration.run(pool);
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_mode')));
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_repeat_count')));
});
test('canvas size enabled migration upgrades an existing 2.15.2 installation', async () => {
const pool = createPool([]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.2' });
assert.equal(pending.length, 1);
assert.equal(pending[0].version, '2.15.3');
await pending[0].run(pool);
assert.ok(pool.queries.some(([sql]) => sql.includes('ALTER TABLE c_canvas_sizes ADD COLUMN enabled')));
});
test('playlist group schema migration is pending for version 2.14.3', async () => {
const pool = createPool([
{
+24
View File
@@ -124,6 +124,30 @@ test('buildTemplatePayload resolves canvas size and rejects duplicate region nam
);
});
test('disabled canvas sizes cannot be assigned to new templates but existing assignments can be retained', async () => {
const pool = {
async query() {
return [[{ id: 4, name: 'Legacy', width: 1280, height: 720, enabled: 0 }]];
}
};
const req = {
body: {
name: 'Legacy template',
canvas_size_id: '4',
regions_json: JSON.stringify([{ region_name: 'Body', region_type: 'text' }])
},
files: []
};
await assert.rejects(
() => buildTemplatePayload(pool, req, null),
(error) => error && error.statusCode === 400 && error.message === 'Canvas size is disabled.'
);
const payload = await buildTemplatePayload(pool, req, { canvas_size_id: 4 });
assert.equal(payload.canvasSizeId, 4);
});
test('buildDuplicateTemplate copies template fields and strips row identity', () => {
const duplicate = buildDuplicateTemplate({
id: 9,