From a5f9636ad838d910a621491b0b14b559a3c082e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 02:17:38 +0000 Subject: [PATCH 1/2] feat(dashboard): add random characters to the selector of new dashboards Scenes generate their URL selector with 4 random characters at the end to avoid collisions between scenes sharing the same name slug. Dashboards used the plain slug of their name, so two dashboards whose names slugify to the same value collided on the unique selector column. New dashboards now get their selector from slugify(name, true), exactly like scenes. A selector explicitly given by the caller (API, Cypress fixtures) is still kept as is, and existing dashboards are untouched: no migration rewrites their selector, so their URLs keep working. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRdJPgpjHkz9LKu39n8fm8 --- .../e2e/routes/dashboard/Dashboard.cy.js | 3 +- server/lib/dashboard/dashboard.create.js | 14 +++++- .../lib/dashboard/dashboard.create.test.js | 44 ++++++++++++++++++- .../dashboard/dashboard.updateOrder.test.js | 2 +- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/front/cypress/e2e/routes/dashboard/Dashboard.cy.js b/front/cypress/e2e/routes/dashboard/Dashboard.cy.js index bf9b464e0d..626bd29aaa 100644 --- a/front/cypress/e2e/routes/dashboard/Dashboard.cy.js +++ b/front/cypress/e2e/routes/dashboard/Dashboard.cy.js @@ -22,7 +22,8 @@ describe('Dashboard', () => { .should('have.class', 'btn-primary') .click(); - cy.url().should('eq', `${Cypress.config().baseUrl}/dashboard/my-new-dashboard/edit`); + // The selector of a new dashboard ends with 4 random characters, like scenes + cy.url().should('match', new RegExp(`^${Cypress.config().baseUrl}/dashboard/my-new-dashboard-[a-z0-9]{4}/edit$`)); }); it('Should add new boxes', () => { cy.contains('.btn-primary', 'dashboard.addBoxButton').click(); diff --git a/server/lib/dashboard/dashboard.create.js b/server/lib/dashboard/dashboard.create.js index 66c0a62b2a..185da4acc9 100644 --- a/server/lib/dashboard/dashboard.create.js +++ b/server/lib/dashboard/dashboard.create.js @@ -1,4 +1,5 @@ const db = require('../../models'); +const { slugify } = require('../../utils/slugify'); /** * @description Create a new dashboard. @@ -26,7 +27,18 @@ async function create(userId, dashboard) { if (dashboardWithTheHighestPosition.length > 0) { dashboard.position = dashboardWithTheHighestPosition[0].position + 1; } - return db.Dashboard.create({ ...dashboard, user_id: userId }); + let dashboardWithSelector = dashboard; + // Like scenes, the selector of a new dashboard gets random characters at the + // end so two dashboards with names sharing the same slug don't collide. + // A selector explicitly given by the caller is always kept as is, and + // existing dashboards keep the selector they were created with. + if (!dashboard.selector && dashboard.name) { + dashboardWithSelector = { + ...dashboard, + selector: slugify(dashboard.name, true), + }; + } + return db.Dashboard.create({ ...dashboardWithSelector, user_id: userId }); } module.exports = { diff --git a/server/test/lib/dashboard/dashboard.create.test.js b/server/test/lib/dashboard/dashboard.create.test.js index 781b2a5c11..7acb501a0c 100644 --- a/server/test/lib/dashboard/dashboard.create.test.js +++ b/server/test/lib/dashboard/dashboard.create.test.js @@ -5,7 +5,7 @@ const Dashboard = require('../../../lib/dashboard'); describe('dashboard.create', () => { const dashboard = new Dashboard(); - it('should create a dashboard', async () => { + it('should create a dashboard with a random selector', async () => { const newDashboard = await dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', { name: 'My new dashboard', type: DASHBOARD_TYPE.MAIN, @@ -20,7 +20,47 @@ describe('dashboard.create', () => { ], }); expect(newDashboard).to.have.property('name', 'My new dashboard'); - expect(newDashboard).to.have.property('selector', 'my-new-dashboard'); + expect(newDashboard.selector).to.contain('my-new-dashboard'); + // selector should have 4 random characters at the end + dash + expect(newDashboard.selector).to.have.lengthOf('my-new-dashboard'.length + 5); + }); + it('should create a dashboard with the selector given', async () => { + const newDashboard = await dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', { + name: 'My dashboard with a custom selector', + selector: 'my-custom-dashboard-selector', + type: DASHBOARD_TYPE.MAIN, + position: 0, + visibility: DASHBOARD_VISIBILITY.PRIVATE, + boxes: [[]], + }); + expect(newDashboard).to.have.property('selector', 'my-custom-dashboard-selector'); + }); + it('should create two dashboards with names sharing the same slug', async () => { + const firstDashboard = await dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', { + name: 'Salon', + type: DASHBOARD_TYPE.MAIN, + position: 0, + visibility: DASHBOARD_VISIBILITY.PRIVATE, + boxes: [[]], + }); + const secondDashboard = await dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', { + name: 'SalĂ´n', + type: DASHBOARD_TYPE.MAIN, + position: 0, + visibility: DASHBOARD_VISIBILITY.PRIVATE, + boxes: [[]], + }); + expect(firstDashboard.selector).to.contain('salon'); + expect(secondDashboard.selector).to.contain('salon'); + expect(firstDashboard.selector).to.not.equal(secondDashboard.selector); + }); + it('should return error, missing name', async () => { + const promise = dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', { + type: DASHBOARD_TYPE.MAIN, + visibility: DASHBOARD_VISIBILITY.PRIVATE, + boxes: [[]], + }); + return assert.isRejected(promise); }); it('should return error, missing box type', async () => { const promise = dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', { diff --git a/server/test/lib/dashboard/dashboard.updateOrder.test.js b/server/test/lib/dashboard/dashboard.updateOrder.test.js index a58babb872..c2a5c36fe4 100644 --- a/server/test/lib/dashboard/dashboard.updateOrder.test.js +++ b/server/test/lib/dashboard/dashboard.updateOrder.test.js @@ -39,7 +39,7 @@ describe('dashboard.updateOrder', () => { raw: true, }); expect(dashboardsInNewOrder).to.deep.equal([ - { selector: 'my-new-dashboard', position: 0 }, + { selector: newDashboard.selector, position: 0 }, { selector: 'test-dashboard', position: 1 }, { selector: 'my-new-public-dashoard', position: 2 }, ]); From 425bedc0805d23b01fd877881251cee8b6c728d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 08:03:32 +0000 Subject: [PATCH 2/2] test(dashboard): assert the full generated selector format The creation test only checked that the selector contained the name slug and had the expected length, which also passed for values that are not the generated format (a missing separator, or non-alphanumeric characters). Match the whole selector against the exact shape instead. Autofix-Pass: 1 --- server/test/lib/dashboard/dashboard.create.test.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/test/lib/dashboard/dashboard.create.test.js b/server/test/lib/dashboard/dashboard.create.test.js index 7acb501a0c..37ce0bb9bd 100644 --- a/server/test/lib/dashboard/dashboard.create.test.js +++ b/server/test/lib/dashboard/dashboard.create.test.js @@ -20,9 +20,8 @@ describe('dashboard.create', () => { ], }); expect(newDashboard).to.have.property('name', 'My new dashboard'); - expect(newDashboard.selector).to.contain('my-new-dashboard'); - // selector should have 4 random characters at the end + dash - expect(newDashboard.selector).to.have.lengthOf('my-new-dashboard'.length + 5); + // selector should be the slug of the name + a dash + 4 random characters + expect(newDashboard.selector).to.match(/^my-new-dashboard-[a-z0-9]{4}$/); }); it('should create a dashboard with the selector given', async () => { const newDashboard = await dashboard.create('0cd30aef-9c4e-4a23-88e3-3547971296e5', {