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. 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 ## 2.15.2 - 2026-10-15
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.15.2", "version": "2.15.3",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.15.2", "version": "2.15.3",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.2", "version": "2.15.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.2", "version": "2.15.3",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.15.2", "version": "2.15.3",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+2 -2
View File
@@ -4,7 +4,7 @@ const { fetchPagedRows } = require('#src/data/utils');
async function fetchAdminData(pool) { 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 [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(` 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, 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 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) { async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { 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 (SELECT COUNT(*) FROM c_templates st WHERE st.canvas_size_id = c_canvas_sizes.id) AS template_count
FROM c_canvas_sizes FROM c_canvas_sizes
ORDER BY width ASC, height ASC, name ASC`, ORDER BY width ASC, height ASC, name ASC`,
+7 -4
View File
@@ -3,14 +3,17 @@
const { fetchPagedRows } = require('#src/data/utils'); const { fetchPagedRows } = require('#src/data/utils');
const MAX_CANVAS_SIZE_DIMENSION = 16384; const MAX_CANVAS_SIZE_DIMENSION = 16384;
async function fetchCanvasSizesData(pool) { async function fetchCanvasSizesData(pool, includeDisabledId) {
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 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 }; return { canvasSizes };
} }
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { 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', countSql: 'SELECT COUNT(*) AS count FROM c_canvas_sizes',
searchColumns: ['name', 'width', 'height'], searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm, searchTerm: searchTerm,
@@ -30,7 +33,7 @@ async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, s
} }
async function fetchCanvasSizeById(pool, id) { 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; return rows[0] || null;
} }
+6 -1
View File
@@ -264,13 +264,18 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let resolvedCanvasSizeId = canvasSizeId; let resolvedCanvasSizeId = canvasSizeId;
if (resolvedCanvasSizeId) { 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]; const canvasSize = canvasSizes[0];
if (!canvasSize) { if (!canvasSize) {
const error = new Error('Canvas size not found.'); const error = new Error('Canvas size not found.');
error.statusCode = 400; error.statusCode = 400;
throw error; 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); canvasWidth = Number(canvasSize.width);
canvasHeight = Number(canvasSize.height); canvasHeight = Number(canvasSize.height);
} else { } else {
+1
View File
@@ -29,6 +29,7 @@ async function ensureSchema(pool, options) {
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
width INT NOT NULL, width INT NOT NULL,
height INT NOT NULL, height INT NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL, created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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_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'); 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 hasAnyPermission = deps.hasAnyPermission;
const { buildPagination } = require('#src/web/lib/pagination'); const { buildPagination } = require('#src/web/lib/pagination');
const { buildDuplicateSlideName, buildDuplicateSlide } = require('#src/web/routes/signage/slides/duplicate'); 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 LIST_PAGE_SIZE = 25;
const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024; const WYSIWYG_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024;
@@ -337,7 +336,11 @@ module.exports = function registerContentRoutes(app, deps) {
duplicateIndex += 1; 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) { } catch (error) {
next(error); next(error);
} }
@@ -749,7 +752,7 @@ module.exports = function registerContentRoutes(app, deps) {
} }
template.region_usage = await fetchTemplateRegionUsage(template); template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0; 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); await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = await fetchMediaAssets(pool); const mediaAssets = await fetchMediaAssets(pool);
const settings = await fetchAppSettings(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)); 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) { app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
try { try {
const payload = common.buildCanvasSizePayload(req, null); 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) { app.post('/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
try { try {
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); 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.'); throw createValidationError('Canvas size not found.');
} }
requestedCanvasId = Number(canvasSize.id); 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) { 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.'); 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) { if (!canvasSize) {
return res.status(400).send('Canvas size not found.'); 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); const actorId = getAuditUserId(req);
await connection.beginTransaction(); 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]); 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, name: size.name,
width: size.width, width: size.width,
height: size.height, height: size.height,
enabled: size.enabled !== 0 && size.enabled !== false,
templateCount: size.template_count !== undefined templateCount: size.template_count !== undefined
? Number(size.template_count) || 0 ? Number(size.template_count) || 0
: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length : (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 computedPlaylistCanvasId = playlistSlideCanvasIds.length === 1 ? playlistSlideCanvasIds[0] : null;
const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId; const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId;
const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null; const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null;
const canvasSizes = (data.canvasSizes || []).map((size) => Object.assign({}, size, { const canvasSizes = (data.canvasSizes || []).filter((size) => {
label: formatCanvasSizeLabel(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, canvasId: Number(size.id) || null,
selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id)) selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id))
})); }));
+8 -4
View File
@@ -24,6 +24,7 @@
<tr> <tr>
<th data-table-sort-key="name">Name</th> <th data-table-sort-key="name">Name</th>
<th data-table-sort-key="dimensions">Dimensions</th> <th data-table-sort-key="dimensions">Dimensions</th>
<th>Status</th>
<th data-table-sort-key="templates">Templates</th> <th data-table-sort-key="templates">Templates</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -34,15 +35,18 @@
<tr data-table-search-row> <tr data-table-search-row>
<td data-label="Name">{{name}}</td> <td data-label="Name">{{name}}</td>
<td data-label="Dimensions">{{width}}x{{height}}</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="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"> <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"> <div class="actions">
{{#if (hasPermission ../currentUser 'canvas-sizes.update')}} {{#if (hasPermission ../currentUser 'canvas-sizes.update')}}
<a class="btn btn-sm btn-primary" href="/canvas-sizes/{{id}}/edit">Edit</a> <a class="btn btn-sm btn-primary" href="/canvas-sizes/{{id}}/edit">Edit</a>
{{/if}} {{/if}}
{{#if (hasPermission ../currentUser 'canvas-sizes.create')}} {{#if (hasPermission ../currentUser 'canvas-sizes.update')}}
<a class="btn btn-sm btn-secondary" href="/canvas-sizes/{{id}}/duplicate">Dupe</a> <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}}
{{#if (hasPermission ../currentUser 'canvas-sizes.delete')}} {{#if (hasPermission ../currentUser 'canvas-sizes.delete')}}
<form class="inline-form" method="post" action="/canvas-sizes/{{id}}/delete" data-confirm-message="Delete this canvas size?"> <form class="inline-form" method="post" action="/canvas-sizes/{{id}}/delete" data-confirm-message="Delete this canvas size?">
@@ -61,7 +65,7 @@
</tr> </tr>
{{/each}} {{/each}}
{{else}} {{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}} {{/if}}
</tbody> </tbody>
</table> </table>
+1 -1
View File
@@ -82,7 +82,7 @@
{{/unless}} {{/unless}}
{{#if canvasSizes.length}} {{#if canvasSizes.length}}
{{#each canvasSizes}} {{#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}} {{/each}}
{{/if}} {{/if}}
</select> </select>
+57 -20
View File
@@ -7,9 +7,10 @@ const path = require('node:path');
require('../src/common'); require('../src/common');
const { buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('../src/data'); 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 { 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 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 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 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'); 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')); assert.ok(canvasSizeFormTemplate.includes('data-limit-input-value'));
}); });
test('buildDuplicateCanvasSize copies canvas size fields for the create page', () => { test('canvas size list replaces duplicate action with status and toggle controls', () => {
const duplicate = buildDuplicateCanvasSize({ assert.doesNotMatch(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/duplicate/);
id: 7, assert.match(canvasSizeListTemplate, /data-label="Status"/);
name: 'Full HD', assert.match(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/toggle/);
width: 1920, assert.match(canvasSizeListTemplate, />\{\{#if enabled\}\}Disable\{\{else\}\}Enable\{\{\/if\}\}<\/button>/);
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 template includes duplicate action', () => { test('canvas size list renderer preserves enabled state for status labels', () => {
assert.match(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/duplicate/); const html = renderCanvasSizesPage({
assert.match(canvasSizeListTemplate, />Dupe<\/a>/); 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 () => { 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 queries = [];
const toggleUpdates = [];
const auditEvents = [];
let enabled = 1;
const pool = { const pool = {
async query(sql) { async query(sql, params) {
queries.push(sql); 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 = ?')) { 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 = ?')) { if (sql.includes('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?')) {
return [[{ ref_count: 1 }]]; return [[{ ref_count: 1 }]];
@@ -92,7 +118,7 @@ test('canvas size update returns a warning when an in-use canvas changes dimensi
getSortQuery: () => '', getSortQuery: () => '',
getSortDirectionQuery: () => 'asc', getSortDirectionQuery: () => 'asc',
fetchCanvasSizeById: async (connection, id) => { 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; return rows[0] || null;
}, },
buildCanvasSizePayload: buildCanvasSizePayload, buildCanvasSizePayload: buildCanvasSizePayload,
@@ -124,6 +150,7 @@ test('canvas size update returns a warning when an in-use canvas changes dimensi
removeUnusedUploadFiles: async () => {}, removeUnusedUploadFiles: async () => {},
syncPlaylistUploadsOnChange: async () => {}, syncPlaylistUploadsOnChange: async () => {},
getAuditUserId: () => 1, getAuditUserId: () => 1,
recordRequestAuditEvent: async (_connection, _req, event) => auditEvents.push(event),
redirectAfterSave: () => {}, redirectAfterSave: () => {},
notifyPlayerScreens: async () => 0, notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {}, 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.body, 'This canvas size is still used by one or more templates, so its dimensions cannot be changed.');
assert.equal(res.redirectedTo, ''); assert.equal(res.redirectedTo, '');
assert.equal(queries.length, 2); 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 () => { test('slide upload cleanup route removes pending media assets', async () => {
+16
View File
@@ -83,4 +83,20 @@ test('playlist rows use the longest video region duration', () => {
assert.equal(model.playlistSlides[0].showVideoDurationButton, true); assert.equal(model.playlistSlides[0].showVideoDurationButton, true);
assert.equal(model.playlistSlides[0].videoDurationSeconds, 27.75); assert.equal(model.playlistSlides[0].videoDurationSeconds, 27.75);
assert.equal(model.playlistSlides[0].durationSeconds, 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 () => { test('playlist create and update redirect to the canonical edit path', async () => {
const calls = []; const calls = [];
let groupInsertParams = null; let groupInsertParams = null;
let canvasSizeEnabled = 1;
const connection = { const connection = {
async beginTransaction() {}, async beginTransaction() {},
async commit() {}, async commit() {},
@@ -76,7 +77,7 @@ test('playlist create and update redirect to the canonical edit path', async ()
}, },
common: { common: {
async fetchDuplicateName() { return null; }, async fetchDuplicateName() { return null; },
async fetchCanvasSizeById() { return { id: 7 }; }, async fetchCanvasSizeById() { return { id: 7, enabled: canvasSizeEnabled }; },
async fetchPlaylistById() { return { id: 4, canvas_id: 7 }; } async fetchPlaylistById() { return { id: 4, canvas_id: 7 }; }
}, },
pages: {}, pages: {},
@@ -161,6 +162,24 @@ test('playlist create and update redirect to the canonical edit path', async ()
'insert-slide', 'insert-slide',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' } { 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']) { for (const pickCount of ['0', '2']) {
const invalidRes = createResponse(); const invalidRes = createResponse();
await updateHandlers[1]({ 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 () => { test('playlist repeat migration upgrades an existing 2.15.1 installation', async () => {
const pool = createPool([]); const pool = createPool([]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.15.1' }); const pending = await getPendingMigrations(pool, { currentVersion: '2.15.1' });
assert.equal(pending.length, 1); const repeatMigration = pending.find(migration => migration.version === '2.15.2');
assert.equal(pending[0].version, '2.15.2'); assert.ok(repeatMigration);
await pending[0].run(pool); 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_mode')));
assert.ok(pool.queries.some(([sql]) => sql.includes('ADD COLUMN group_repeat_count'))); 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 () => { test('playlist group schema migration is pending for version 2.14.3', async () => {
const pool = createPool([ 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', () => { test('buildDuplicateTemplate copies template fields and strips row identity', () => {
const duplicate = buildDuplicateTemplate({ const duplicate = buildDuplicateTemplate({
id: 9, id: 9,