You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by di...@apache.org on 2022/09/02 16:55:16 UTC

[superset] 03/05: Refactor edit/save dashboard tests

This is an automated email from the ASF dual-hosted git repository.

diegopucci pushed a commit to branch chore/cypress-runtime-enhancements
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 984baf6bf9af0715619e6ad32c46c15ef5b81596
Author: geido <di...@gmail.com>
AuthorDate: Fri Sep 2 14:52:36 2022 +0300

    Refactor edit/save dashboard tests
---
 .../cypress-base/cypress/fixtures/dashboards.json  |  12 +-
 .../integration/dashboard/dashboard.helper.ts      |  24 --
 .../integration/dashboard/edit_mode.test.js        |  98 --------
 .../integration/dashboard/edit_properties.test.ts  | 263 --------------------
 .../cypress/integration/dashboard/editsave.test.ts | 272 +++++++++++++++++++++
 .../cypress/integration/dashboard/markdown.test.ts |   4 +-
 .../integration/dashboard/nativeFilters.test.ts    |   2 +-
 .../cypress/integration/dashboard/save.test.js     | 163 ------------
 .../cypress-base/cypress/support/index.d.ts        |   3 +
 .../cypress-base/cypress/support/index.ts          |  58 ++---
 .../cypress-base/cypress/utils/index.ts            |  23 ++
 .../cypress-base/cypress/utils/urls.ts             |   3 +-
 .../src/components/PageHeaderWithActions/index.tsx |   1 +
 .../components/BuilderComponentPane/index.tsx      |   1 +
 .../src/dashboard/components/Header/index.jsx      |   2 +-
 .../dashboard/components/PropertiesModal/index.tsx |   1 +
 16 files changed, 345 insertions(+), 585 deletions(-)

diff --git a/superset-frontend/cypress-base/cypress/fixtures/dashboards.json b/superset-frontend/cypress-base/cypress/fixtures/dashboards.json
index 6972cb0552..68a9984e41 100644
--- a/superset-frontend/cypress-base/cypress/fixtures/dashboards.json
+++ b/superset-frontend/cypress-base/cypress/fixtures/dashboards.json
@@ -1,14 +1,18 @@
 [
     {
-        "dashboard_title": "1 - Sample dashboard"
+        "dashboard_title": "1 - Sample dashboard",
+        "slug": "1-sample-dashboard"
     },
     {
-        "dashboard_title": "2 - Sample dashboard"
+        "dashboard_title": "2 - Sample dashboard",
+        "slug": "2-sample-dashboard"
     },
     {
-        "dashboard_title": "3 - Sample dashboard"
+        "dashboard_title": "3 - Sample dashboard",
+        "slug": "3-sample-dashboard"
     },
     {
-        "dashboard_title": "4 - Sample dashboard"
+        "dashboard_title": "4 - Sample dashboard",
+        "slug": "4-sample-dashboard"
     }
 ]
\ No newline at end of file
diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts
index 39b7fc40c2..9868eb5b5e 100644
--- a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts
+++ b/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts
@@ -67,30 +67,6 @@ export const ECHARTS_CHARTS = [
   { name: 'Energy Force Layout', viz: 'graph_chart' },
 ] as const;
 
-/**
- * Drag an element and drop it to another element.
- * Usage:
- *    drag(source).to(target);
- */
-export function drag(selector: string, content: string | number | RegExp) {
-  const dataTransfer = { data: {} };
-  return {
-    to(target: string | Cypress.Chainable) {
-      cy.get('.dragdroppable')
-        .contains(selector, content)
-        .trigger('mousedown', { which: 1 })
-        .trigger('dragstart', { dataTransfer })
-        .trigger('drag', {});
-
-      (typeof target === 'string' ? cy.get(target) : target)
-        .trigger('dragover', { dataTransfer })
-        .trigger('drop', { dataTransfer })
-        .trigger('dragend', { dataTransfer })
-        .trigger('mouseup', { which: 1 });
-    },
-  };
-}
-
 export function resize(selector: string) {
   return {
     to(cordX: number, cordY: number) {
diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js
deleted file mode 100644
index 0f646cce3d..0000000000
--- a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { drag } from './dashboard.helper';
-import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
-
-describe('Dashboard edit mode', () => {
-  beforeEach(() => {
-    cy.login();
-    cy.visit(WORLD_HEALTH_DASHBOARD);
-    cy.get('.header-with-actions')
-      .find('[aria-label="Edit dashboard"]')
-      .click();
-  });
-
-  it('remove, and add chart flow', () => {
-    // wait for box plot to appear
-    cy.get('[data-test="grid-container"]').find('.box_plot', {
-      timeout: 10000,
-    });
-    const elementsCount = 10;
-
-    cy.get('[data-test="dashboard-component-chart-holder"]')
-      .find('[data-test="dashboard-delete-component-button"]')
-      .last()
-      .then($el => {
-        cy.wrap($el).invoke('show').click();
-        // box plot should be gone
-        cy.get('[data-test="grid-container"]')
-          .find('.box_plot')
-          .should('not.exist');
-      });
-
-    // find box plot is available from list
-    cy.get('[data-test="dashboard-charts-filter-search-input"]').type(
-      'Box plot',
-    );
-    cy.get('[data-test="card-title"]').should('have.length', 1);
-
-    drag('[data-test="card-title"]', 'Box plot').to(
-      '.grid-row.background--transparent:last',
-    );
-
-    // add back to dashboard
-    cy.get('[data-test="grid-container"]')
-      .find('.box_plot')
-      .should('be.visible');
-
-    // should show Save changes button
-    cy.get('[data-test="header-save-button"]').should('be.visible');
-
-    // undo first step and expect deleted item
-    cy.get('[data-test="undo-action"]').click();
-    cy.get('[data-test="grid-container"]')
-      .find('[data-test="chart-container"]')
-      .should('have.length', elementsCount - 1);
-
-    // Box plot chart should be gone
-    cy.get('[data-test="grid-container"]')
-      .find('.box_plot')
-      .should('not.exist');
-
-    // undo second step and expect initial items count
-    cy.get('[data-test="undo-action"]').click();
-    cy.get('[data-test="grid-container"]')
-      .find('[data-test="chart-container"]')
-      .should('have.length', elementsCount);
-    cy.get('[data-test="card-title"]').contains('Box plot', { timeout: 5000 });
-
-    // save changes button should be disabled
-    cy.get('[data-test="header-save-button"]').should('be.disabled');
-
-    // no changes, can switch to view mode
-    cy.get('[data-test="dashboard-edit-actions"]')
-      .find('[data-test="discard-changes-button"]')
-      .should('be.visible')
-      .click();
-    cy.get('.header-with-actions').within(() => {
-      cy.get('[data-test="dashboard-edit-actions"]').should('not.be.visible');
-      cy.get('[aria-label="Edit dashboard"]').should('be.visible');
-    });
-  });
-});
diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_properties.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_properties.test.ts
deleted file mode 100644
index 57839ebc2c..0000000000
--- a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_properties.test.ts
+++ /dev/null
@@ -1,263 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-// eslint-disable-next-line import/no-extraneous-dependencies
-import * as ace from 'brace';
-import * as shortid from 'shortid';
-import { USA_BIRTH_NAMES_DASHBOARD } from './dashboard.helper';
-
-function selectColorScheme(color: string) {
-  // open color scheme dropdown
-  cy.get('.ant-modal-body')
-    .contains('Color scheme')
-    .parents('.ControlHeader')
-    .next('.ant-select')
-    .click()
-    .then($colorSelect => {
-      // select a new color scheme
-      cy.wrap($colorSelect).find(`[data-test="${color}"]`).click();
-    });
-}
-
-function assertMetadata(text: string) {
-  const regex = new RegExp(text);
-  cy.get('.ant-modal-body')
-    .find('#json_metadata')
-    .should('be.visible')
-    .then(() => {
-      const metadata = cy.$$('#json_metadata')[0];
-
-      // cypress can read this locally, but not in ci
-      // so we have to use the ace module directly to fetch the value
-      expect(ace.edit(metadata).getValue()).to.match(regex);
-    });
-}
-function clear(input: string) {
-  cy.get(input).type('{selectall}{backspace}');
-}
-function type(input: string, text: string) {
-  cy.get(input).type(text, { parseSpecialCharSequences: false });
-}
-
-function openAdvancedProperties() {
-  return cy
-    .get('.ant-modal-body')
-    .contains('Advanced')
-    .should('be.visible')
-    .click();
-}
-
-function openDashboardEditProperties() {
-  // open dashboard properties edit modal
-  cy.get(
-    '.header-with-actions .right-button-panel .ant-dropdown-trigger',
-  ).trigger('click', {
-    force: true,
-  });
-  cy.get('[data-test=header-actions-menu]')
-    .contains('Edit properties')
-    .click({ force: true });
-}
-
-describe('Dashboard edit action', () => {
-  beforeEach(() => {
-    cy.login();
-    cy.visit(USA_BIRTH_NAMES_DASHBOARD);
-    cy.intercept(`/api/v1/dashboard/births`).as('dashboardGet');
-    cy.get('.dashboard-grid', { timeout: 50000 })
-      .should('be.visible') // wait for 50 secs to load dashboard
-      .then(() => {
-        cy.get('.header-with-actions [aria-label="Edit dashboard"]')
-          .should('be.visible')
-          .click();
-        openDashboardEditProperties();
-      });
-  });
-
-  it('should update the title', () => {
-    const dashboardTitle = `Test dashboard [${shortid.generate()}]`;
-
-    // update title
-    cy.get('.ant-modal-body')
-      .should('be.visible')
-      .contains('Title')
-      .get('[data-test="dashboard-title-input"]')
-      .type(`{selectall}{backspace}${dashboardTitle}`);
-
-    // save edit changes
-    cy.get('.ant-modal-footer')
-      .contains('Apply')
-      .click()
-      .then(() => {
-        // assert that modal edit window has closed
-        cy.get('.ant-modal-body').should('not.exist');
-
-        // assert title has been updated
-        cy.get('[data-test="editable-title-input"]').should(
-          'have.value',
-          dashboardTitle,
-        );
-      });
-  });
-  describe('the color picker is changed', () => {
-    describe('the metadata has a color scheme', () => {
-      describe('the advanced tab is open', () => {
-        it('should overwrite the color scheme', () => {
-          openAdvancedProperties();
-          selectColorScheme('d3Category20b');
-          assertMetadata('d3Category20b');
-        });
-      });
-      describe('the advanced tab is not open', () => {
-        it('should overwrite the color scheme', () => {
-          selectColorScheme('bnbColors');
-          openAdvancedProperties();
-          assertMetadata('bnbColors');
-        });
-      });
-    });
-  });
-  describe('a valid colorScheme is entered', () => {
-    it('should save json metadata color change to dropdown', () => {
-      // edit json metadata
-      openAdvancedProperties().then(() => {
-        clear('#json_metadata');
-        type('#json_metadata', '{"color_scheme":"d3Category20"}');
-      });
-
-      // save edit changes
-      cy.get('.ant-modal-footer')
-        .contains('Apply')
-        .click()
-        .then(() => {
-          // assert that modal edit window has closed
-          cy.get('.ant-modal-body').should('not.exist');
-
-          // assert color has been updated
-          openDashboardEditProperties();
-          openAdvancedProperties().then(() => {
-            assertMetadata('d3Category20');
-          });
-          cy.get('.ant-select-selection-item .color-scheme-option').should(
-            'have.attr',
-            'data-test',
-            'd3Category20',
-          );
-        });
-    });
-  });
-  describe('an invalid colorScheme is entered', () => {
-    it('should throw an error', () => {
-      // edit json metadata
-      openAdvancedProperties().then(() => {
-        clear('#json_metadata');
-        type('#json_metadata', '{"color_scheme":"THIS_DOES_NOT_WORK"}');
-      });
-
-      // save edit changes
-      cy.get('.ant-modal-footer')
-        .contains('Apply')
-        .click()
-        .then(() => {
-          // assert that modal edit window has closed
-          cy.get('.ant-modal-body')
-            .contains('A valid color scheme is required')
-            .should('be.visible');
-        });
-
-      cy.on('uncaught:exception', err => {
-        expect(err.message).to.include('something about the error');
-
-        // return false to prevent the error from
-        // failing this test
-        return false;
-      });
-    });
-  });
-  describe.skip('the color scheme affects the chart colors', () => {
-    it('should change the chart colors', () => {
-      openAdvancedProperties().then(() => {
-        clear('#json_metadata');
-        type(
-          '#json_metadata',
-          '{"color_scheme":"lyftColors", "label_colors": {}}',
-        );
-      });
-
-      cy.get('.ant-modal-footer')
-        .contains('Apply')
-        .click()
-        .then(() => {
-          cy.get('.ant-modal-body').should('not.exist');
-          // assert that the chart has changed colors
-          cy.get('.line .nv-legend-symbol')
-            .first()
-            .should('have.css', 'fill', 'rgb(117, 96, 170)');
-        });
-    });
-    it('the label colors should take precedence over the scheme', () => {
-      openAdvancedProperties().then(() => {
-        clear('#json_metadata');
-        type(
-          '#json_metadata',
-          '{"color_scheme":"lyftColors","label_colors":{"Amanda":"red"}}',
-        );
-      });
-
-      cy.get('.ant-modal-footer')
-        .contains('Apply')
-        .click()
-        .then(() => {
-          cy.get('.ant-modal-body').should('not.exist');
-          // assert that the chart has changed colors
-          cy.get('.line .nv-legend-symbol')
-            .first()
-            .should('have.css', 'fill', 'rgb(255, 0, 0)');
-        });
-    });
-    it('the shared label colors and label colors are applied correctly', () => {
-      openAdvancedProperties().then(() => {
-        clear('#json_metadata');
-        type(
-          '#json_metadata',
-          '{"color_scheme":"lyftColors","label_colors":{"Amanda":"red"}}',
-        );
-      });
-
-      cy.get('.ant-modal-footer')
-        .contains('Apply')
-        .click()
-        .then(() => {
-          cy.get('.ant-modal-body').should('not.exist');
-          // assert that the chart has changed colors
-          cy.get('.line .nv-legend-symbol')
-            .first()
-            .should('have.css', 'fill', 'rgb(255, 0, 0)'); // label: amanda
-          cy.get('.line .nv-legend-symbol')
-            .eq(11)
-            .should('have.css', 'fill', 'rgb(234, 11, 140)'); // label: jennifer
-          cy.get('.word_cloud')
-            .first()
-            .find('svg text')
-            .first()
-            .should('have.css', 'fill', 'rgb(234, 11, 140)'); // label: jennifer
-        });
-    });
-  });
-});
diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/editsave.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/editsave.test.ts
new file mode 100644
index 0000000000..f456d9473a
--- /dev/null
+++ b/superset-frontend/cypress-base/cypress/integration/dashboard/editsave.test.ts
@@ -0,0 +1,272 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
+import { drag } from 'cypress/utils';
+import {
+  interceptUpdate,
+} from './utils';
+import * as ace from 'brace';
+import {interceptFiltering as interceptCharts} from '../explore/utils';
+
+function editDashboard() {
+  cy.getBySel('edit-dashboard-button').click();
+}
+
+function closeModal() {
+  cy.getBySel('properties-modal-cancel-button').click({force: true});
+}
+
+function openProperties() {
+  cy.get('body')
+  .then($body => {
+    if ($body.find('[data-test="properties-modal-cancel-button"]').length) {
+      closeModal();
+    }
+  });
+  cy.getBySel('actions-trigger').click({ force: true} );
+  cy.getBySel('header-actions-menu').contains('Edit properties').click({ force: true} );
+  cy.wait(500);
+}
+
+function openAdvancedProperties() {
+  return cy
+    .get('.ant-modal-body')
+    .contains('Advanced')
+    .should('be.visible')
+    .click({ force: true });
+}
+
+function dragChart(chart = 'Unicode Cloud') {
+  drag('[data-test="card-title"]', chart).to(
+    '[data-test="grid-content"] [data-test="dragdroppable-object"]',
+  );
+}
+
+function discardChanges() {
+  cy.getBySel('undo-action').click({ force: true });
+}
+
+function visitEdit() {
+  interceptCharts();
+  cy.visit(SAMPLE_DASHBOARD_1);
+  editDashboard();
+  cy.wait('@filtering');
+}
+
+function selectColorScheme(color: string) {
+    cy.get('[data-test="dashboard-edit-properties-form"] [aria-label="Select color scheme"]').first().click();
+    cy.getBySel(color).click();
+}
+
+function applyChanges() {
+  cy.getBySel('properties-modal-apply-button').click();
+}
+
+function saveChanges() {
+  interceptUpdate();
+  cy.getBySel('header-save-button').click();
+  cy.wait('@update');
+}
+
+function assertMetadata(text: string) {
+  const regex = new RegExp(text);
+  cy.get('.ant-modal-body')
+    .find('#json_metadata')
+    .should('be.visible')
+    .then(() => {
+      const metadata = cy.$$('#json_metadata')[0];
+
+      // cypress can read this locally, but not in ci
+      // so we have to use the ace module directly to fetch the value
+      expect(ace.edit(metadata).getValue()).to.match(regex);
+    });
+}
+function clearAll(input: string) {
+  return cy.get(input).type('{selectall}{backspace}');
+}
+
+describe('Dashboard edit', () => {
+  beforeEach(() => {
+    cy.preserveLogin();
+  });
+
+  describe('Edit mode', () => {
+    before(() => {
+      cy.createSampleDashboards();
+      visitEdit();
+    });
+
+    beforeEach(() => {
+      discardChanges();
+    });
+
+    it('should enable edit mode', () => {
+      cy.getBySel('dashboard-builder-sidepane').should('be.visible');
+    });
+
+    it('should edit the title inline', () => {
+      cy.getBySel('editable-title-input').clear().type('Edited title{enter}');
+      cy.getBySel('header-save-button').should('be.enabled');
+    });
+
+    it('should filter charts', () => {
+      interceptCharts();
+      cy.getBySel('dashboard-charts-filter-search-input').type('Unicode');
+      cy.wait('@filtering');
+      cy.getBySel('chart-card').should('have.length', 1).contains('Unicode Cloud');
+      cy.getBySel('dashboard-charts-filter-search-input').clear();
+    });
+
+  });
+
+  describe('Components', () => {
+    before(() => {
+      cy.createSampleDashboards();
+    });
+
+    beforeEach(() => {
+      visitEdit();
+    });
+
+    it('should add charts', () => {
+      dragChart();
+      cy.getBySel('dashboard-component-chart-holder').should('have.length', 1);
+    });
+
+    it('should remove added charts', () => {
+      dragChart('% Rural');
+      cy.getBySel('dashboard-component-chart-holder').should('have.length', 1);
+      cy.getBySel('dashboard-delete-component-button').click();
+      cy.getBySel('dashboard-component-chart-holder').should('have.length', 0);
+    });
+  });
+
+  describe('Edit properties', () => {
+    before(() => {
+      cy.createSampleDashboards();
+      visitEdit();
+    });
+
+    beforeEach(() => {
+      openProperties();
+    });
+
+    it('should overwrite the color scheme when advanced is closed', () => {
+      selectColorScheme('d3Category20b');
+      openAdvancedProperties();
+      assertMetadata('d3Category20b');
+      applyChanges();
+    });
+
+    it('should overwrite the color scheme when advanced is open', () => {
+      openAdvancedProperties();
+      selectColorScheme('googleCategory10c');
+      assertMetadata('googleCategory10c');
+      applyChanges();
+    });
+
+    it('should accept a valid color scheme', () => {
+      openAdvancedProperties();
+      clearAll('#json_metadata').then(() => {
+        cy.get('#json_metadata').type('{"color_scheme":"lyftColors"}', { parseSpecialCharSequences: false })
+        applyChanges();
+        openProperties();
+        openAdvancedProperties();
+        assertMetadata('lyftColors');
+        applyChanges();
+      })
+
+    });
+
+    it('should not accept an invalid color scheme', () => {
+      openAdvancedProperties();
+      clearAll('#json_metadata').then(() => {
+        cy.get('#json_metadata').type('{"color_scheme":"wrongcolorscheme"}', { parseSpecialCharSequences: false })
+        applyChanges();
+        cy.get('.ant-modal-body')
+          .contains('A valid color scheme is required')
+          .should('be.visible');
+      })
+    });
+
+    it('should edit the title', () => {
+      cy.getBySel('dashboard-title-input').clear().type('Edited title');
+      applyChanges();
+      cy.getBySel('editable-title-input').should('have.value', 'Edited title');
+    });
+  });
+
+  describe('Color schemes', () => {
+    beforeEach(() => {
+      cy.createSampleDashboards();
+      visitEdit();
+    });
+
+    it('should apply a valid color scheme', () => {
+      dragChart('Top 10 California Names Timeseries');
+      openProperties();
+      selectColorScheme('lyftColors');
+      applyChanges();
+      saveChanges();
+      cy.get('.line .nv-legend-symbol')
+        .first()
+        .should('have.css', 'fill', 'rgb(234, 11, 140)');
+    });
+
+    it('label colors should take the precedence', () => {
+      dragChart('Top 10 California Names Timeseries');
+      openProperties();
+      openAdvancedProperties();
+      clearAll('#json_metadata').then(() => {
+        cy.get('#json_metadata').type('{"color_scheme":"lyftColors","label_colors":{"Anthony":"red"}}', { parseSpecialCharSequences: false })
+        applyChanges();
+        saveChanges();
+        cy.get('.line .nv-legend-symbol')
+          .first()
+          .should('have.css', 'fill', 'rgb(255, 0, 0)');
+      });
+    });
+  });
+
+  describe('Save', () => {
+    before(() => {
+      cy.createSampleDashboards();
+      visitEdit();
+    });
+
+    beforeEach(() => {
+      discardChanges();
+    })
+
+    it('should disable saving when undoing', () => {
+      dragChart();
+      cy.getBySel('header-save-button').should('be.enabled');
+      discardChanges();
+      cy.getBySel('header-save-button').should('be.disabled');
+    });
+
+    it('should save', () => {
+      dragChart();
+      cy.getBySel('header-save-button').should('be.enabled');
+      saveChanges();
+      cy.getBySel('dashboard-component-chart-holder').should('have.length', 1);
+      cy.getBySel('edit-dashboard-button').should('be.visible');
+    });
+  });
+});
diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts
index a27382933b..5d7d818508 100644
--- a/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts
+++ b/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts
@@ -16,8 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { TABBED_DASHBOARD, drag, resize } from './dashboard.helper';
-
+import { TABBED_DASHBOARD, resize } from './dashboard.helper';
+import { drag } from 'cypress/utils';
 describe('Dashboard edit markdown', () => {
   beforeEach(() => {
     cy.login();
diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts
index 024f7b6fa3..8b8c048862 100644
--- a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts
+++ b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts
@@ -553,7 +553,7 @@ xdescribe('Nativefilters', () => {
     cy.get('[data-test="Treemap-list-chart-title"]')
       .should('be.visible', { timeout: 5000 })
       .click();
-    cy.get('[data-test="query-save-button"]').click();
+    cy.get('[data-test="edit-dashboard-button"]').click();
     cy.get('[data-test="save-chart-modal-select-dashboard-form"]')
       .find('input[aria-label="Select a dashboard"]')
       .type(`${dashboard}`, { force: true });
diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js b/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js
deleted file mode 100644
index 07be4ef626..0000000000
--- a/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import shortid from 'shortid';
-import {
-  waitForChartLoad,
-} from 'cypress/utils';
-import { WORLD_HEALTH_CHARTS } from './utils';
-import { WORLD_HEALTH_DASHBOARD } from 'cypress/utils/urls';
-
-function openDashboardEditProperties() {
-  // open dashboard properties edit modal
-  cy.get('.header-with-actions [aria-label="Edit dashboard"]')
-    .should('be.visible')
-    .click();
-  cy.get(
-    '.header-with-actions .right-button-panel .ant-dropdown-trigger',
-  ).trigger('click', {
-    force: true,
-  });
-  cy.get('[data-test=header-actions-menu]')
-    .contains('Edit properties')
-    .click({ force: true });
-}
-
-describe('Dashboard save action', () => {
-  beforeEach(() => {
-    cy.login();
-    cy.visit(WORLD_HEALTH_DASHBOARD);
-    cy.get('#app').then(() => {
-      cy.get('.dashboard-header-container').then(headerContainerElement => {
-        const dashboardId = headerContainerElement.attr('data-test-id');
-
-        cy.intercept('POST', `/superset/copy_dash/${dashboardId}/`).as(
-          'copyRequest',
-        );
-
-        cy.get('[aria-label="more-horiz"]').trigger('click', { force: true });
-        cy.get('[data-test="save-as-menu-item"]').trigger('click', {
-          force: true,
-        });
-        cy.get('[data-test="modal-save-dashboard-button"]').trigger('click', {
-          force: true,
-        });
-      });
-    });
-  });
-
-  // change to what the title should be
-  it('should save as new dashboard', () => {
-    cy.wait('@copyRequest').then(() => {
-      cy.get('[data-test="editable-title"]').then(element => {
-        const dashboardTitle = element.attr('title');
-        expect(dashboardTitle).to.not.equal(`World Bank's Data`);
-      });
-    });
-  });
-
-  it('should save/overwrite dashboard', () => {
-    // should load chart
-    WORLD_HEALTH_CHARTS.forEach(waitForChartLoad);
-
-    // remove box_plot chart from dashboard
-    cy.get('[aria-label="Edit dashboard"]').click({ timeout: 5000 });
-    cy.get('[data-test="dashboard-delete-component-button"]')
-      .last()
-      .trigger('mouseenter')
-      .click();
-
-    cy.get('[data-test="grid-container"]')
-      .find('.box_plot')
-      .should('not.exist');
-
-    cy.intercept('PUT', '/api/v1/dashboard/**').as('putDashboardRequest');
-    cy.get('.header-with-actions')
-      .find('[data-test="header-save-button"]')
-      .contains('Save')
-      .click();
-
-    // go back to view mode
-    cy.wait('@putDashboardRequest');
-    cy.get('.header-with-actions')
-      .find('[aria-label="Edit dashboard"]')
-      .click();
-
-    // deleted boxplot should still not exist
-    cy.get('[data-test="grid-container"]')
-      .find('.box_plot', { timeout: 20000 })
-      .should('not.exist');
-  });
-
-  it('should save after edit', () => {
-    cy.get('.dashboard-grid', { timeout: 50000 }) // wait for 50 secs to load dashboard
-      .then(() => {
-        const dashboardTitle = `Test dashboard [${shortid.generate()}]`;
-
-        openDashboardEditProperties();
-
-        // open color scheme dropdown
-        cy.get('.ant-modal-body')
-          .contains('Color scheme')
-          .parents('.ControlHeader')
-          .next('.ant-select')
-          .click()
-          .then(() => {
-            // select a new color scheme
-            cy.get('.ant-modal-body')
-              .find('.ant-select-item-option-active')
-              .first()
-              .click();
-          });
-
-        // remove json metadata
-        cy.get('.ant-modal-body')
-          .contains('Advanced')
-          .click()
-          .then(() => {
-            cy.get('#json_metadata').type('{selectall}{backspace}');
-          });
-
-        // update title
-        cy.get('[data-test="dashboard-title-input"]').type(
-          `{selectall}{backspace}${dashboardTitle}`,
-        );
-
-        // save edit changes
-        cy.get('.ant-modal-footer')
-          .contains('Apply')
-          .click()
-          .then(() => {
-            // assert that modal edit window has closed
-            cy.get('.ant-modal-body').should('not.exist');
-
-            // save dashboard changes
-            cy.get('.header-with-actions').contains('Save').click();
-
-            // assert success flash
-            cy.contains('saved successfully').should('be.visible');
-
-            // assert title has been updated
-            cy.get(
-              '.header-with-actions .title-panel [data-test="editable-title"]',
-            ).should('have.text', dashboardTitle);
-          });
-      });
-  });
-});
diff --git a/superset-frontend/cypress-base/cypress/support/index.d.ts b/superset-frontend/cypress-base/cypress/support/index.d.ts
index fc065563d3..a5011090ee 100644
--- a/superset-frontend/cypress-base/cypress/support/index.d.ts
+++ b/superset-frontend/cypress-base/cypress/support/index.d.ts
@@ -38,6 +38,9 @@ declare namespace Cypress {
 
     getBySel(selector: string): cy;
     getBySelLike(selector: string): cy;
+    getSampleData(): void;
+    cleanCharts(): void;
+    cleanDashboards(): void;
 
     visitChartByParams(params: string | Record<string, unknown>): cy;
     visitChartByName(name: string): cy;
diff --git a/superset-frontend/cypress-base/cypress/support/index.ts b/superset-frontend/cypress-base/cypress/support/index.ts
index 5f201200b5..3c24d167c3 100644
--- a/superset-frontend/cypress-base/cypress/support/index.ts
+++ b/superset-frontend/cypress-base/cypress/support/index.ts
@@ -26,36 +26,36 @@ const TokenName = Cypress.env('TOKEN_NAME');
 let SAMPLE_DASHBOARDS: Record<string, any>[] = [];
 let SAMPLE_CHARTS: Record<string, any>[] = [];
 
-function resetSamples() {
-  cy.login();
-  cy.fixture('dashboards.json').then(dashboards => {
-    dashboards.forEach((d: { dashboard_title: string }) => {
-      cy.deleteDashboardByName(d.dashboard_title, false);
-    });
-  });
-  cy.fixture('charts.json').then(charts => {
-    charts.forEach((c: { slice_name: string }) => {
-      cy.deleteChartByName(c.slice_name, false);
-    });
-  });
-}
-
-function loadSampleData() {
-  cy.login();
+Cypress.Commands.add('getSampleData', () =>
   cy.getCharts().then((slices: any) => {
     SAMPLE_CHARTS = slices;
-  });
-  cy.getDashboards().then((dashboards: any) => {
-    SAMPLE_DASHBOARDS = dashboards;
-  });
-}
+    cy.getDashboards().then((dashboards: any) => {
+      SAMPLE_DASHBOARDS = dashboards;
+    });
+  })
+);
 
-before(() => {
-  loadSampleData();
-});
+Cypress.Commands.add('cleanDashboards', () =>
+  cy.fixture('dashboards.json').then(dashboards => {
+    for (let i = 0; i < dashboards.length; i += 1) {
+      cy.deleteDashboardByName(dashboards[i].dashboard_title, false)
+    }
+  })
+);
 
-beforeEach(() => {
-  resetSamples();
+Cypress.Commands.add('cleanCharts', () =>
+  cy.fixture('charts.json').then(charts => {
+    for (let i = 0; i < charts.length; i += 1) {
+      cy.deleteChartByName(charts[i].slice_name, false)
+    }
+  })
+);
+
+before(() => {
+  cy.login();
+  cy.cleanDashboards();
+  cy.cleanCharts();
+  cy.getSampleData();
 });
 
 Cypress.Commands.add('getBySel', (selector, ...args) =>
@@ -189,6 +189,7 @@ Cypress.Commands.add(
 
 Cypress.Commands.add('createSampleDashboards', () => {
   const requests: any = [];
+  cy.cleanDashboards();
   cy.fixture('dashboards.json').then(dashboards => {
     for (let i = 0; i < dashboards.length; i += 1) {
       requests.push(
@@ -208,11 +209,12 @@ Cypress.Commands.add('createSampleDashboards', () => {
         }),
       );
     }
-    return Promise.all(requests).then(() => loadSampleData());
+    return Promise.all(requests).then(() => cy.getSampleData());
   });
 });
 
 Cypress.Commands.add('createSampleCharts', () => {
+  cy.cleanCharts();
   const requests: any = [];
   return cy.fixture('charts.json').then(charts => {
     for (let i = 0; i < charts.length; i += 1) {
@@ -233,7 +235,7 @@ Cypress.Commands.add('createSampleCharts', () => {
         }),
       );
     }
-    return Promise.all(requests).then(() => loadSampleData());
+    return Promise.all(requests).then(() => cy.getSampleData());
   });
 });
 
diff --git a/superset-frontend/cypress-base/cypress/utils/index.ts b/superset-frontend/cypress-base/cypress/utils/index.ts
index 3bdbb0f122..949817c84a 100644
--- a/superset-frontend/cypress-base/cypress/utils/index.ts
+++ b/superset-frontend/cypress-base/cypress/utils/index.ts
@@ -86,3 +86,26 @@ export function waitForChartLoad(chart: ChartSpec) {
     });
   }
 
+/**
+ * Drag an element and drop it to another element.
+ * Usage:
+ *    drag(source).to(target);
+ */
+ export function drag(selector: string, content: string | number | RegExp) {
+    const dataTransfer = { data: {} };
+    return {
+      to(target: string | Cypress.Chainable) {
+        cy.get('.dragdroppable')
+          .contains(selector, content)
+          .trigger('mousedown', { which: 1, force: true })
+          .trigger('dragstart', { dataTransfer, force: true })
+          .trigger('drag', {force: true});
+
+        (typeof target === 'string' ? cy.get(target) : target)
+          .trigger('dragover', { dataTransfer, force: true })
+          .trigger('drop', { dataTransfer, force: true })
+          .trigger('dragend', { dataTransfer, force: true })
+          .trigger('mouseup', { which: 1, force: true });
+      },
+    };
+  }
\ No newline at end of file
diff --git a/superset-frontend/cypress-base/cypress/utils/urls.ts b/superset-frontend/cypress-base/cypress/utils/urls.ts
index 2f3cc4d56c..40eb51a170 100644
--- a/superset-frontend/cypress-base/cypress/utils/urls.ts
+++ b/superset-frontend/cypress-base/cypress/utils/urls.ts
@@ -19,4 +19,5 @@
 
 export const DASHBOARD_LIST = '/dashboard/list/';
 export const CHART_LIST = '/chart/list/';
-export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/';
\ No newline at end of file
+export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/';
+export const SAMPLE_DASHBOARD_1 = '/superset/dashboard/1-sample-dashboard/';
\ No newline at end of file
diff --git a/superset-frontend/src/components/PageHeaderWithActions/index.tsx b/superset-frontend/src/components/PageHeaderWithActions/index.tsx
index e85ccdfc82..19f1ded4a7 100644
--- a/superset-frontend/src/components/PageHeaderWithActions/index.tsx
+++ b/superset-frontend/src/components/PageHeaderWithActions/index.tsx
@@ -152,6 +152,7 @@ export const PageHeaderWithActions = ({
               css={menuTriggerStyles}
               buttonStyle="tertiary"
               aria-label={t('Menu actions trigger')}
+              data-test="actions-trigger"
             >
               <Icons.MoreHoriz
                 iconColor={theme.colors.primary.dark2}
diff --git a/superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx b/superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx
index 7a1019a0e2..39e7fd4bc4 100644
--- a/superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx
+++ b/superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx
@@ -64,6 +64,7 @@ const BuilderComponentPane: React.FC<BCPProps> = ({
   <DashboardBuilderSidepane
     topOffset={topOffset}
     className="dashboard-builder-sidepane"
+    data-test="dashboard-builder-sidepane"
   >
     <ParentSize>
       {({ height }) => (
diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx
index 19b7c21ce2..354ee81bbc 100644
--- a/superset-frontend/src/dashboard/components/Header/index.jsx
+++ b/superset-frontend/src/dashboard/components/Header/index.jsx
@@ -618,7 +618,7 @@ class Header extends React.PureComponent {
                     <Button
                       buttonStyle="secondary"
                       onClick={this.toggleEditMode}
-                      data-test="query-save-button"
+                      data-test="edit-dashboard-button"
                       className="action-button"
                       css={editButtonStyle}
                       aria-label={t('Edit dashboard')}
diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
index ebdc753226..f05b4081a5 100644
--- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
+++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
@@ -538,6 +538,7 @@ const PropertiesModal = ({
             {t('Cancel')}
           </Button>
           <Button
+            data-test="properties-modal-apply-button"
             onClick={form.submit}
             buttonSize="small"
             buttonStyle="primary"