diff --git a/front/cypress/e2e/routes/dashboard/Dashboard.cy.js b/front/cypress/e2e/routes/dashboard/Dashboard.cy.js
index bf9b464e0d..3a3e4139e1 100644
--- a/front/cypress/e2e/routes/dashboard/Dashboard.cy.js
+++ b/front/cypress/e2e/routes/dashboard/Dashboard.cy.js
@@ -35,10 +35,10 @@ describe('Dashboard', () => {
cy.contains('dashboard.editDashboardButton')
.should('have.class', 'btn-outline-primary')
.click();
- cy.contains('dashboard.editDashboardDeleteButton')
+ cy.contains('button', 'dashboard.editDashboardDeleteButton')
.should('have.class', 'btn-outline-danger')
.click();
- cy.contains('dashboard.editDashboardDeleteButton')
+ cy.contains('button', 'dashboard.editDashboardDeleteButton')
.should('have.class', 'btn-outline-danger')
.click();
cy.url().should('eq', `${Cypress.config().baseUrl}/dashboard`);
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..d373e6cf9e 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,17 @@
"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}}",
+ "savedVersionInfo": "Die Kopie wird von der zuletzt gespeicherten Version dieses Dashboards erstellt. Nicht gespeicherte Änderungen werden nicht kopiert."
+ },
"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..427de90fea 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,17 @@
"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}}",
+ "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}}\"",
"nameLabel": "Name",
diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json
index 152327809a..b4a3bbee4c 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,17 @@
"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}}",
+ "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}}\"",
"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..41418e144c
--- /dev/null
+++ b/front/src/routes/dashboard/duplicate-dashboard/DuplicateDashboardPage.jsx
@@ -0,0 +1,75 @@
+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..c90607d280
--- /dev/null
+++ b/front/src/routes/dashboard/duplicate-dashboard/index.js
@@ -0,0 +1,130 @@
+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 = (name = this.state.dashboard.name) => {
+ const duplicateDashboardErrors = {};
+ if (!name) {
+ duplicateDashboardErrors.name = true;
+ }
+ this.setState({
+ duplicateDashboardErrors
+ });
+ return Object.keys(duplicateDashboardErrors).length > 0;
+ };
+
+ updateDuplicateDashboardName = e => {
+ const { value } = e.target;
+ this.setState({
+ dashboard: {
+ name: value
+ }
+ });
+ if (this.state.duplicateDashboardErrors) {
+ // setState is asynchronous, so we validate the new value directly
+ this.checkErrors(value);
+ }
+ };
+
+ 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..f1b15b9eb3 100644
--- a/front/src/routes/dashboard/edit-dashboard/EditActions.jsx
+++ b/front/src/routes/dashboard/edit-dashboard/EditActions.jsx
@@ -1,20 +1,59 @@
-import { Text } from 'preact-i18n';
+import { Text, Localizer } from 'preact-i18n';
const EditActions = props => (