From f0601f823da5ef5878d07560f6b2c23ba9cf7377 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Thu, 15 Oct 2026 02:09:34 +0100 Subject: [PATCH] Add canvas size disable controls --- CHANGELOG.md | 6 ++ build/package.player.json | 2 +- build/package.web.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/data/admin.js | 4 +- src/data/canvas-sizes.js | 11 ++- src/data/templates.js | 7 +- src/db/index.js | 1 + src/db/migrations.js | 7 ++ src/web/routes/admin/content.js | 44 +++++------ src/web/routes/admin/playlists.js | 6 ++ .../routes/signage/canvas-sizes/duplicate.js | 21 ----- src/web/routes/signage/canvas-sizes/list.js | 1 + .../signage/playlists/form-view-model.js | 7 +- src/web/views/signage/canvas-sizes/list.hbs | 12 ++- src/web/views/signage/templates/form.hbs | 2 +- test/canvas-sizes.test.js | 77 ++++++++++++++----- test/playlist-form-view-model.test.js | 16 ++++ test/playlists-routes.test.js | 21 ++++- test/schema-update-log.test.js | 15 +++- test/templates.test.js | 24 ++++++ 22 files changed, 205 insertions(+), 87 deletions(-) delete mode 100644 src/web/routes/signage/canvas-sizes/duplicate.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1d5bb..1876a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/build/package.player.json b/build/package.player.json index 68b4701..4fd3d52 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -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": { diff --git a/build/package.web.json b/build/package.web.json index 4bb6ec4..c1e3337 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index 75c7d04..4bc5295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 91376ec..d416c62 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/data/admin.js b/src/data/admin.js index 87ef535..4473879 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -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`, diff --git a/src/data/canvas-sizes.js b/src/data/canvas-sizes.js index 636fa7c..9f23c7b 100644 --- a/src/data/canvas-sizes.js +++ b/src/data/canvas-sizes.js @@ -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; } diff --git a/src/data/templates.js b/src/data/templates.js index 596f75f..197f13a 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -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 { diff --git a/src/db/index.js b/src/db/index.js index 20b0d60..c655820 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -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, diff --git a/src/db/migrations.js b/src/db/migrations.js index eac5b11..a9c7cdf 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -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'); + } } ]; diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index 20804f4..4a999e7 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -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)); diff --git a/src/web/routes/admin/playlists.js b/src/web/routes/admin/playlists.js index 573db76..35e3a7c 100644 --- a/src/web/routes/admin/playlists.js +++ b/src/web/routes/admin/playlists.js @@ -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]); diff --git a/src/web/routes/signage/canvas-sizes/duplicate.js b/src/web/routes/signage/canvas-sizes/duplicate.js deleted file mode 100644 index 4b92cc0..0000000 --- a/src/web/routes/signage/canvas-sizes/duplicate.js +++ /dev/null @@ -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 -}; \ No newline at end of file diff --git a/src/web/routes/signage/canvas-sizes/list.js b/src/web/routes/signage/canvas-sizes/list.js index 5df0b34..18b3be6 100644 --- a/src/web/routes/signage/canvas-sizes/list.js +++ b/src/web/routes/signage/canvas-sizes/list.js @@ -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 diff --git a/src/web/routes/signage/playlists/form-view-model.js b/src/web/routes/signage/playlists/form-view-model.js index 8566be3..c9cba5c 100644 --- a/src/web/routes/signage/playlists/form-view-model.js +++ b/src/web/routes/signage/playlists/form-view-model.js @@ -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)) })); diff --git a/src/web/views/signage/canvas-sizes/list.hbs b/src/web/views/signage/canvas-sizes/list.hbs index 4ec1cec..49f6155 100644 --- a/src/web/views/signage/canvas-sizes/list.hbs +++ b/src/web/views/signage/canvas-sizes/list.hbs @@ -24,6 +24,7 @@ Name Dimensions + Status Templates Actions @@ -34,15 +35,18 @@ {{name}} {{width}}x{{height}} + {{#if enabled}}Enabled{{else}}Disabled{{/if}} {{templateCount}} template{{#if (gt templateCount 1)}}s{{else}}{{/if}}{{#if (lt templateCount 1)}}s{{/if}} - {{#if (anyPermission ../currentUser 'canvas-sizes.create' 'canvas-sizes.update' 'canvas-sizes.delete')}} + {{#if (anyPermission ../currentUser 'canvas-sizes.update' 'canvas-sizes.delete')}}
{{#if (hasPermission ../currentUser 'canvas-sizes.update')}} Edit {{/if}} - {{#if (hasPermission ../currentUser 'canvas-sizes.create')}} - Dupe + {{#if (hasPermission ../currentUser 'canvas-sizes.update')}} +
+ +
{{/if}} {{#if (hasPermission ../currentUser 'canvas-sizes.delete')}}
@@ -61,7 +65,7 @@ {{/each}} {{else}} - No canvas sizes yet. + No canvas sizes yet. {{/if}} diff --git a/src/web/views/signage/templates/form.hbs b/src/web/views/signage/templates/form.hbs index 6bb8730..53c1589 100644 --- a/src/web/views/signage/templates/form.hbs +++ b/src/web/views/signage/templates/form.hbs @@ -82,7 +82,7 @@ {{/unless}} {{#if canvasSizes.length}} {{#each canvasSizes}} - + {{/each}} {{/if}} diff --git a/test/canvas-sizes.test.js b/test/canvas-sizes.test.js index d22953f..beb6dd9 100644 --- a/test/canvas-sizes.test.js +++ b/test/canvas-sizes.test.js @@ -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">Enabled<\/span>/); + assert.match(html, /data-label="Status">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 () => { diff --git a/test/playlist-form-view-model.test.js b/test/playlist-form-view-model.test.js index 6c919d2..960140b 100644 --- a/test/playlist-form-view-model.test.js +++ b/test/playlist-form-view-model.test.js @@ -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].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); }); \ No newline at end of file diff --git a/test/playlists-routes.test.js b/test/playlists-routes.test.js index cf6366f..be17161 100644 --- a/test/playlists-routes.test.js +++ b/test/playlists-routes.test.js @@ -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]({ diff --git a/test/schema-update-log.test.js b/test/schema-update-log.test.js index d68f31b..8e09ebe 100644 --- a/test/schema-update-log.test.js +++ b/test/schema-update-log.test.js @@ -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([ { diff --git a/test/templates.test.js b/test/templates.test.js index 4c6c4db..ecf1e5b 100644 --- a/test/templates.test.js +++ b/test/templates.test.js @@ -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,