From 0c1b368b4149d3d24ca8430f24a69100801ab73e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 02:39:04 +0000 Subject: [PATCH 1/2] Add the ability to duplicate a dashboard Dashboards can now be duplicated the same way scenes can: - server: new `dashboard.duplicate` lib function, mirroring `scene.duplicate`. It copies the boxes and the type of the source dashboard, gets a new unique selector (slugify with random suffix) and is created for the user asking for it, at the end of their dashboard list. The source lookup follows the existing dashboard permission model (a dashboard I created or a public one). A public dashboard of another user is duplicated as a private dashboard, so a copy is never re-shared with the whole installation. - server: `POST /api/v1/dashboard/:dashboard_selector/duplicate` route and controller. - front: "Duplicate" button on the dashboard edit page, opening a new `/dashboard/:dashboardSelector/duplicate` page prefilled with "Copy of ", like the scene duplication page. - i18n: new `duplicateDashboard` keys and `dashboard.editDashboardDuplicateButton` in en, fr and de. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRdJPgpjHkz9LKu39n8fm8 --- front/src/components/app.jsx | 2 + front/src/config/i18n/de.json | 11 ++ front/src/config/i18n/en.json | 11 ++ front/src/config/i18n/fr.json | 11 ++ .../DuplicateDashboardPage.jsx | 72 ++++++++++ .../dashboard/duplicate-dashboard/index.js | 128 ++++++++++++++++++ .../dashboard/duplicate-dashboard/style.css | 3 + .../dashboard/edit-dashboard/EditActions.jsx | 3 + .../routes/dashboard/edit-dashboard/index.js | 5 + .../api/controllers/dashboard.controller.js | 13 ++ server/api/routes.js | 4 + server/lib/dashboard/dashboard.duplicate.js | 61 +++++++++ server/lib/dashboard/index.js | 2 + .../dashboard/dashboard.controller.test.js | 36 +++++ .../lib/dashboard/dashboard.duplicate.test.js | 72 ++++++++++ 15 files changed, 434 insertions(+) create mode 100644 front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx create mode 100644 front/src/routes/dashboard/duplicate-dashboard/index.js create mode 100644 front/src/routes/dashboard/duplicate-dashboard/style.css create mode 100644 server/lib/dashboard/dashboard.duplicate.js create mode 100644 server/test/lib/dashboard/dashboard.duplicate.test.js diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index a137bc1776..41e4219bfc 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -46,6 +46,7 @@ import SignupSuccess from '../routes/signup/5-success'; import Dashboard from '../routes/dashboard'; import NewDashboard from '../routes/dashboard/new-dashboard'; import EditDashboard from '../routes/dashboard/edit-dashboard'; +import DuplicateDashboard from '../routes/dashboard/duplicate-dashboard'; import IntegrationPage from '../routes/integration'; import DevicesListPage from '../routes/devices'; @@ -266,6 +267,7 @@ const AppRouter = connect( + diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 4703487ea2..ff4ae9db13 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -291,6 +291,7 @@ "disableFullScreen": "Vollbild beenden", "editDashboardTitle": "Dashboard bearbeiten", "editDashboardDeleteButton": "Löschen", + "editDashboardDuplicateButton": "Duplizieren", "editDashboardCancelButton": "Abbrechen", "editDashboardDeleteText": "Bist du sicher, dass du dieses Dashboard löschen möchtest?", "toggleDefineTabletMode": "Tablet-Modus", @@ -4007,6 +4008,16 @@ "invalidIcon": "Symbol ist erforderlich", "sceneAlreadyExist": "Eine Szene mit diesem Namen existiert bereits." }, + "duplicateDashboard": { + "cardTitle": "Dashboard \"{{name}}\" duplizieren", + "nameLabel": "Name", + "namePlaceholder": "Gib einen neuen Namen ein", + "duplicateDashboardButton": "Duplizieren", + "invalidName": "Name ist erforderlich", + "dashboardAlreadyExist": "Ein Dashboard mit diesem Namen existiert bereits.", + "unknownError": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut!", + "nameAfterCopy": "Kopie von {{name}}" + }, "duplicateScene": { "cardTitle": "Szene \"{{name}}\" duplizieren", "nameLabel": "Name", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 3080417573..2a14f12336 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -291,6 +291,7 @@ "disableFullScreen": "Exit full screen", "editDashboardTitle": "Edit dashboard", "editDashboardDeleteButton": "Delete", + "editDashboardDuplicateButton": "Duplicate", "editDashboardCancelButton": "Cancel", "editDashboardDeleteText": "Are you sure you want to delete this dashboard?", "toggleDefineTabletMode": "Tablet Mode", @@ -4007,6 +4008,16 @@ "invalidIcon": "Icon is required", "sceneAlreadyExist": "A scene with that name already exists." }, + "duplicateDashboard": { + "cardTitle": "Duplicate dashboard \"{{name}}\"", + "nameLabel": "Name", + "namePlaceholder": "Enter a new name", + "duplicateDashboardButton": "Duplicate", + "invalidName": "Name is required", + "dashboardAlreadyExist": "A dashboard with this name already exists.", + "unknownError": "An unknown error occurred. Please retry!", + "nameAfterCopy": "Copy of {{name}}" + }, "duplicateScene": { "cardTitle": "Duplicate scene \"{{name}}\"", "nameLabel": "Name", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 152327809a..4abd56085e 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -289,6 +289,7 @@ "newDashboardButton": "Nouveau", "editDashboardCancelButton": "Annuler", "editDashboardDeleteButton": "Supprimer", + "editDashboardDuplicateButton": "Dupliquer", "editDashboardSaveButton": "Sauvegarder", "editDashboardDeleteText": "Êtes-vous sûr de vouloir supprimer ce tableau de bord ?", "toggleDefineTabletMode": "Mode tablette", @@ -4007,6 +4008,16 @@ "invalidIcon": "L'icône est requise", "sceneAlreadyExist": "Une scène avec le même nom existe déjà." }, + "duplicateDashboard": { + "cardTitle": "Dupliquer le tableau de bord \"{{name}}\"", + "nameLabel": "Nom", + "namePlaceholder": "Entrez un nouveau nom", + "duplicateDashboardButton": "Dupliquer", + "invalidName": "Le nom est requis", + "dashboardAlreadyExist": "Un tableau de bord avec le même nom existe déjà.", + "unknownError": "Une erreur inconnue est survenue. Veuillez réessayer !", + "nameAfterCopy": "Copie de {{name}}" + }, "duplicateScene": { "cardTitle": "Dupliquer la scène \"{{name}}\"", "nameLabel": "Nom", diff --git a/front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx b/front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx new file mode 100644 index 0000000000..4f0c682948 --- /dev/null +++ b/front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx @@ -0,0 +1,72 @@ +import { Text, Localizer } from 'preact-i18n'; +import cx from 'classnames'; +import get from 'get-value'; + +import { RequestStatus } from '../../../utils/consts'; +import style from './style.css'; + +const DuplicateDashboardPage = ({ children, ...props }) => ( +
+ + +
+ +
+); + +export default DuplicateDashboardPage; diff --git a/front/src/routes/dashboard/duplicate-dashboard/index.js b/front/src/routes/dashboard/duplicate-dashboard/index.js new file mode 100644 index 0000000000..937033ec9b --- /dev/null +++ b/front/src/routes/dashboard/duplicate-dashboard/index.js @@ -0,0 +1,128 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { route } from 'preact-router'; +import get from 'get-value'; + +import DuplicateDashboardPage from './DuplicateDashboardPage'; +import { RequestStatus } from '../../../utils/consts'; +import withIntlAsProp from '../../../utils/withIntlAsProp'; + +class DuplicateDashboard extends Component { + goBack = () => { + route(`/dashboard/${this.props.dashboardSelector}/edit`); + }; + + getSourceDashboard = async () => { + try { + const sourceDashboard = await this.props.httpClient.get(`/api/v1/dashboard/${this.props.dashboardSelector}`); + this.setState({ + sourceDashboard, + loading: false, + dashboard: { + name: get(this.props.intl.dictionary, 'duplicateDashboard.nameAfterCopy').replace( + '{{name}}', + sourceDashboard.name + ) + } + }); + } catch (e) { + console.error(e); + this.setState({ + loading: false, + duplicateDashboardStatus: RequestStatus.Error + }); + } + }; + + checkErrors = () => { + const duplicateDashboardErrors = {}; + if (!this.state.dashboard.name) { + duplicateDashboardErrors.name = true; + } + this.setState({ + duplicateDashboardErrors + }); + return Object.keys(duplicateDashboardErrors).length > 0; + }; + + updateDuplicateDashboardName = e => { + this.setState({ + dashboard: { + name: e.target.value + } + }); + if (this.state.duplicateDashboardErrors) { + this.checkErrors(); + } + }; + + duplicateDashboard = async e => { + e.preventDefault(); + // if errored, we don't continue + if (this.checkErrors()) { + return; + } + this.setState({ + duplicateDashboardStatus: RequestStatus.Getting + }); + try { + const duplicatedDashboard = await this.props.httpClient.post( + `/api/v1/dashboard/${this.props.dashboardSelector}/duplicate`, + this.state.dashboard + ); + this.setState({ + duplicateDashboardStatus: RequestStatus.Success + }); + route(`/dashboard/${duplicatedDashboard.selector}/edit`); + } catch (e) { + console.error(e); + const status = get(e, 'response.status'); + if (status === 409) { + this.setState({ + duplicateDashboardStatus: RequestStatus.ConflictError + }); + } else { + this.setState({ + duplicateDashboardStatus: RequestStatus.Error + }); + } + } + }; + + constructor(props) { + super(props); + this.state = { + dashboard: { + name: '' + }, + sourceDashboard: { + name: '' + }, + loading: true, + duplicateDashboardErrors: null, + duplicateDashboardStatus: null + }; + } + + componentDidMount() { + this.getSourceDashboard(); + } + + render(props, { dashboard, sourceDashboard, loading, duplicateDashboardErrors, duplicateDashboardStatus }) { + return ( + + ); + } +} + +export default withIntlAsProp(connect('httpClient', {})(DuplicateDashboard)); diff --git a/front/src/routes/dashboard/duplicate-dashboard/style.css b/front/src/routes/dashboard/duplicate-dashboard/style.css new file mode 100644 index 0000000000..f5d0c084cf --- /dev/null +++ b/front/src/routes/dashboard/duplicate-dashboard/style.css @@ -0,0 +1,3 @@ +.containerWithMargin { + margin-top: 3rem; +} diff --git a/front/src/routes/dashboard/edit-dashboard/EditActions.jsx b/front/src/routes/dashboard/edit-dashboard/EditActions.jsx index 0d298a7929..7e455cbf2d 100644 --- a/front/src/routes/dashboard/edit-dashboard/EditActions.jsx +++ b/front/src/routes/dashboard/edit-dashboard/EditActions.jsx @@ -12,6 +12,9 @@ const EditActions = props => ( + diff --git a/front/src/routes/dashboard/edit-dashboard/index.js b/front/src/routes/dashboard/edit-dashboard/index.js index 52629c5032..a81d3fc469 100644 --- a/front/src/routes/dashboard/edit-dashboard/index.js +++ b/front/src/routes/dashboard/edit-dashboard/index.js @@ -310,6 +310,10 @@ class EditDashboard extends Component { } }; + duplicateCurrentDashboard = () => { + route(`/dashboard/${this.state.currentDashboard.selector}/duplicate`); + }; + askDeleteCurrentDashboard = async () => { await this.setState({ askDeleteDashboard: true @@ -426,6 +430,7 @@ class EditDashboard extends Component { updateBoxConfig={this.updateBoxConfig} updateCurrentDashboardName={this.updateCurrentDashboardName} updateCurrentDashboardVisibility={this.updateCurrentDashboardVisibility} + duplicateCurrentDashboard={this.duplicateCurrentDashboard} askDeleteCurrentDashboard={this.askDeleteCurrentDashboard} cancelDeleteCurrentDashboard={this.cancelDeleteCurrentDashboard} deleteCurrentDashboard={this.deleteCurrentDashboard} diff --git a/server/api/controllers/dashboard.controller.js b/server/api/controllers/dashboard.controller.js index 02e1f70991..009098a77c 100644 --- a/server/api/controllers/dashboard.controller.js +++ b/server/api/controllers/dashboard.controller.js @@ -51,6 +51,18 @@ module.exports = function DashboardController(gladys) { res.json(dashboard); } + /** + * @api {post} /api/v1/dashboard/:dashboard_selector/duplicate duplicate + * @apiName duplicate + * @apiGroup Dashboard + * @apiParam {String} name Name of the duplicated dashboard. + * @apiUse DashboardSuccess + */ + async function duplicate(req, res) { + const dashboard = await gladys.dashboard.duplicate(req.user.id, req.params.dashboard_selector, req.body.name); + res.status(201).json(dashboard); + } + /** * @api {post} /api/v1/dashboard/order updateOrder * @apiName updateOrder @@ -101,6 +113,7 @@ module.exports = function DashboardController(gladys) { return Object.freeze({ create: asyncMiddleware(create), destroy: asyncMiddleware(destroy), + duplicate: asyncMiddleware(duplicate), get: asyncMiddleware(get), getBySelector: asyncMiddleware(getBySelector), update: asyncMiddleware(update), diff --git a/server/api/routes.js b/server/api/routes.js index 79af7effb4..48838e0c07 100644 --- a/server/api/routes.js +++ b/server/api/routes.js @@ -201,6 +201,10 @@ function getRoutes(gladys) { authenticated: true, controller: dashboardController.destroy, }, + 'post /api/v1/dashboard/:dashboard_selector/duplicate': { + authenticated: true, + controller: dashboardController.duplicate, + }, // device 'post /api/v1/device': { authenticated: true, diff --git a/server/lib/dashboard/dashboard.duplicate.js b/server/lib/dashboard/dashboard.duplicate.js new file mode 100644 index 0000000000..7e7a03da25 --- /dev/null +++ b/server/lib/dashboard/dashboard.duplicate.js @@ -0,0 +1,61 @@ +const { Op } = require('sequelize'); + +const db = require('../../models'); +const { NotFoundError } = require('../../utils/coreErrors'); +const { slugify } = require('../../utils/slugify'); +const { DASHBOARD_VISIBILITY } = require('../../utils/constants'); + +/** + * @description Duplicate a dashboard. + * @param {string} userId - The userId querying. + * @param {string} selector - The selector of the source dashboard. + * @param {string} name - The name of the duplicated dashboard. + * @returns {Promise} Resolve with the duplicated dashboard. + * @example + * gladys.dashboard.duplicate('0cd30aef-9c4e-4a23-88e3-3547971296e5', 'main-dashboard', 'Copy of Main dashboard'); + */ +async function duplicate(userId, selector, name) { + const existingDashboard = await db.Dashboard.findOne({ + where: { + // I can duplicate a dashboard I created or a public dashboard + [Op.or]: [ + { + user_id: userId, + }, + { + visibility: 'public', + }, + ], + selector, + }, + }); + + if (existingDashboard === null) { + throw new NotFoundError('Dashboard not found'); + } + + const plainExistingDashboard = existingDashboard.get({ plain: true }); + + // The copy belongs to the user asking for it. Duplicating a public dashboard + // of someone else should not re-share a second copy with the whole + // installation, so only the creator of the source keeps its visibility. + const visibility = + plainExistingDashboard.user_id === userId ? plainExistingDashboard.visibility : DASHBOARD_VISIBILITY.PRIVATE; + + const newDashboard = { + name, + selector: slugify(name, true), + type: plainExistingDashboard.type, + visibility, + boxes: plainExistingDashboard.boxes, + }; + + // create places the new dashboard at the end of the dashboard list of the user + const createdDashboard = await this.create(userId, newDashboard); + + return createdDashboard.get({ plain: true }); +} + +module.exports = { + duplicate, +}; diff --git a/server/lib/dashboard/index.js b/server/lib/dashboard/index.js index d2760415d4..25b366c84e 100644 --- a/server/lib/dashboard/index.js +++ b/server/lib/dashboard/index.js @@ -1,6 +1,7 @@ const { create } = require('./dashboard.create'); const { get } = require('./dashboard.get'); const { destroy } = require('./dashboard.destroy'); +const { duplicate } = require('./dashboard.duplicate'); const { getBySelector } = require('./dashboard.getBySelector'); const { update } = require('./dashboard.update'); const { updateOrder } = require('./dashboard.updateOrder'); @@ -10,6 +11,7 @@ const Dashboard = function Dashboard() {}; Dashboard.prototype.create = create; Dashboard.prototype.destroy = destroy; +Dashboard.prototype.duplicate = duplicate; Dashboard.prototype.get = get; Dashboard.prototype.getBySelector = getBySelector; Dashboard.prototype.update = update; diff --git a/server/test/controllers/dashboard/dashboard.controller.test.js b/server/test/controllers/dashboard/dashboard.controller.test.js index 691a0fe1d9..b044e7157e 100644 --- a/server/test/controllers/dashboard/dashboard.controller.test.js +++ b/server/test/controllers/dashboard/dashboard.controller.test.js @@ -174,6 +174,42 @@ describe('POST /api/v1/dashboard/order', () => { }); }); +describe('POST /api/v1/dashboard/:dashboard_selector/duplicate', () => { + it('should duplicate a dashboard', async () => { + await authenticatedRequest + .post('/api/v1/dashboard/test-dashboard/duplicate') + .send({ + name: 'Copy of Test dashboard', + }) + .expect('Content-Type', /json/) + .expect(201) + .then((res) => { + expect(res.body).to.have.property('name', 'Copy of Test dashboard'); + expect(res.body).to.have.property('type', 'main'); + expect(res.body).to.have.property('visibility', DASHBOARD_VISIBILITY.PRIVATE); + expect(res.body).to.have.property('user_id', '0cd30aef-9c4e-4a23-88e3-3547971296e5'); + expect(res.body.selector).to.contain('copy-of-test-dashboard'); + expect(res.body.boxes).to.deep.equal([ + [ + { + type: 'weather', + }, + ], + ]); + }); + }); + + it('should return 404, dashboard not found', async () => { + await authenticatedRequest + .post('/api/v1/dashboard/not-found-dashboard/duplicate') + .send({ + name: 'Copy of a dashboard', + }) + .expect('Content-Type', /json/) + .expect(404); + }); +}); + describe('DELETE /api/v1/dashboard/:dashboard_selector', () => { it('should patch dashboard', async () => { await authenticatedRequest diff --git a/server/test/lib/dashboard/dashboard.duplicate.test.js b/server/test/lib/dashboard/dashboard.duplicate.test.js new file mode 100644 index 0000000000..d45b335741 --- /dev/null +++ b/server/test/lib/dashboard/dashboard.duplicate.test.js @@ -0,0 +1,72 @@ +const { expect, assert } = require('chai'); +const { DASHBOARD_BOX_TYPE, DASHBOARD_TYPE, DASHBOARD_VISIBILITY } = require('../../../utils/constants'); + +const Dashboard = require('../../../lib/dashboard'); + +const USER_ID = '0cd30aef-9c4e-4a23-88e3-3547971296e5'; +const OTHER_USER_ID = '7a137a56-069e-4996-8816-36558174b727'; + +describe('dashboard.duplicate', () => { + const dashboard = new Dashboard(); + + it('should duplicate a dashboard', async () => { + const duplicatedDashboard = await dashboard.duplicate(USER_ID, 'test-dashboard', 'Copy of Test dashboard'); + expect(duplicatedDashboard).to.have.property('name', 'Copy of Test dashboard'); + expect(duplicatedDashboard).to.have.property('type', DASHBOARD_TYPE.MAIN); + expect(duplicatedDashboard).to.have.property('user_id', USER_ID); + expect(duplicatedDashboard).to.have.property('visibility', DASHBOARD_VISIBILITY.PRIVATE); + // The duplicate is placed after the dashboards the user already has + expect(duplicatedDashboard).to.have.property('position', 1); + // The boxes of the source dashboard are copied as is + expect(duplicatedDashboard.boxes).to.deep.equal([[{ type: DASHBOARD_BOX_TYPE.WEATHER }]]); + // The selector is new and unique: slug + dash + 4 random characters + expect(duplicatedDashboard.selector).to.contain('copy-of-test-dashboard'); + expect(duplicatedDashboard.selector).to.have.lengthOf('copy-of-test-dashboard'.length + 5); + }); + + it('should duplicate my own public dashboard and keep it public', async () => { + const publicDashboard = await dashboard.create(USER_ID, { + name: 'My public dashboard', + type: DASHBOARD_TYPE.MAIN, + visibility: DASHBOARD_VISIBILITY.PUBLIC, + boxes: [[{ type: DASHBOARD_BOX_TYPE.USER_PRESENCE }]], + }); + const duplicatedDashboard = await dashboard.duplicate( + USER_ID, + publicDashboard.selector, + 'Copy of my public dashboard', + ); + expect(duplicatedDashboard).to.have.property('user_id', USER_ID); + expect(duplicatedDashboard).to.have.property('visibility', DASHBOARD_VISIBILITY.PUBLIC); + expect(duplicatedDashboard.boxes).to.deep.equal([[{ type: DASHBOARD_BOX_TYPE.USER_PRESENCE }]]); + }); + + it('should duplicate a public dashboard of another user as a private dashboard', async () => { + const publicDashboard = await dashboard.create(OTHER_USER_ID, { + name: 'Public dashboard of another user', + type: DASHBOARD_TYPE.MAIN, + visibility: DASHBOARD_VISIBILITY.PUBLIC, + boxes: [[{ type: DASHBOARD_BOX_TYPE.USER_PRESENCE }]], + }); + const duplicatedDashboard = await dashboard.duplicate(USER_ID, publicDashboard.selector, 'Copy of a public one'); + // The copy belongs to the user asking for it, and is not re-shared + expect(duplicatedDashboard).to.have.property('user_id', USER_ID); + expect(duplicatedDashboard).to.have.property('visibility', DASHBOARD_VISIBILITY.PRIVATE); + }); + + it('should return not found', async () => { + const promise = dashboard.duplicate(USER_ID, 'not-found-dashboard', 'New dashboard'); + return assert.isRejected(promise, 'Dashboard not found'); + }); + + it('should return not found for a private dashboard I cannot see', async () => { + const privateDashboard = await dashboard.create(OTHER_USER_ID, { + name: 'Private dashboard of another user', + type: DASHBOARD_TYPE.MAIN, + visibility: DASHBOARD_VISIBILITY.PRIVATE, + boxes: [[{ type: DASHBOARD_BOX_TYPE.USER_PRESENCE }]], + }); + const promise = dashboard.duplicate(USER_ID, privateDashboard.selector, 'New dashboard'); + return assert.isRejected(promise, 'Dashboard not found'); + }); +}); From ae47f444aac7361df5bceffa1726bb2cbd7d4a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 10:55:33 +0000 Subject: [PATCH 2/2] fix: address review feedback on dashboard duplication - front: validate the name being typed in the duplicate dashboard form instead of the value still in the state. `setState` is asynchronous, so `checkErrors` was reading the previous name and the "name is required" error stayed visible one input event too long. - front: state on the duplicate page that the copy is made from the last saved version of the dashboard. The Duplicate button sits next to Save in the editor and navigates away, so unsaved boxes or name changes are not part of the copy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J6Co85GZkVGZsZJJQMLaPF Autofix-Pass: 1 --- front/src/config/i18n/de.json | 3 ++- front/src/config/i18n/en.json | 3 ++- front/src/config/i18n/fr.json | 3 ++- .../duplicate-dashboard/DuplicateDashboardPage.jsx | 3 +++ .../src/routes/dashboard/duplicate-dashboard/index.js | 10 ++++++---- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index ff4ae9db13..d373e6cf9e 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -4016,7 +4016,8 @@ "invalidName": "Name ist erforderlich", "dashboardAlreadyExist": "Ein Dashboard mit diesem Namen existiert bereits.", "unknownError": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut!", - "nameAfterCopy": "Kopie von {{name}}" + "nameAfterCopy": "Kopie von {{name}}", + "savedVersionInfo": "Die Kopie wird von der zuletzt gespeicherten Version dieses Dashboards erstellt. Nicht gespeicherte Änderungen werden nicht kopiert." }, "duplicateScene": { "cardTitle": "Szene \"{{name}}\" duplizieren", diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 2a14f12336..427de90fea 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -4016,7 +4016,8 @@ "invalidName": "Name is required", "dashboardAlreadyExist": "A dashboard with this name already exists.", "unknownError": "An unknown error occurred. Please retry!", - "nameAfterCopy": "Copy of {{name}}" + "nameAfterCopy": "Copy of {{name}}", + "savedVersionInfo": "The copy is made from the last saved version of this dashboard. Any change not saved yet will not be copied." }, "duplicateScene": { "cardTitle": "Duplicate scene \"{{name}}\"", diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 4abd56085e..b4a3bbee4c 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -4016,7 +4016,8 @@ "invalidName": "Le nom est requis", "dashboardAlreadyExist": "Un tableau de bord avec le même nom existe déjà.", "unknownError": "Une erreur inconnue est survenue. Veuillez réessayer !", - "nameAfterCopy": "Copie de {{name}}" + "nameAfterCopy": "Copie de {{name}}", + "savedVersionInfo": "La copie est réalisée à partir de la dernière version enregistrée de ce tableau de bord. Les modifications non enregistrées ne seront pas copiées." }, "duplicateScene": { "cardTitle": "Dupliquer la scène \"{{name}}\"", diff --git a/front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx b/front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx index 4f0c682948..41418e144c 100644 --- a/front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx +++ b/front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx @@ -21,6 +21,9 @@ const DuplicateDashboardPage = ({ children, ...props }) => (
+
+ +
{props.duplicateDashboardStatus === RequestStatus.ConflictError && (
diff --git a/front/src/routes/dashboard/duplicate-dashboard/index.js b/front/src/routes/dashboard/duplicate-dashboard/index.js index 937033ec9b..c90607d280 100644 --- a/front/src/routes/dashboard/duplicate-dashboard/index.js +++ b/front/src/routes/dashboard/duplicate-dashboard/index.js @@ -34,9 +34,9 @@ class DuplicateDashboard extends Component { } }; - checkErrors = () => { + checkErrors = (name = this.state.dashboard.name) => { const duplicateDashboardErrors = {}; - if (!this.state.dashboard.name) { + if (!name) { duplicateDashboardErrors.name = true; } this.setState({ @@ -46,13 +46,15 @@ class DuplicateDashboard extends Component { }; updateDuplicateDashboardName = e => { + const { value } = e.target; this.setState({ dashboard: { - name: e.target.value + name: value } }); if (this.state.duplicateDashboardErrors) { - this.checkErrors(); + // setState is asynchronous, so we validate the new value directly + this.checkErrors(value); } };