diff --git a/.betterer.results b/.betterer.results index 04a7e341bb6..d19849bcce8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -5,6 +5,9 @@ // exports[`better eslint`] = { value: `{ + "e2e/scenes/utils/support/types.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "e2e/utils/support/types.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -6143,6 +6146,9 @@ exports[`no undocumented stories`] = { exports[`no gf-form usage`] = { value: `{ + "e2e/scenes/utils/flows/addDataSource.ts:5381": [ + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] + ], "e2e/utils/flows/addDataSource.ts:5381": [ [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5edcbe69e91..f1898318c7d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -697,6 +697,7 @@ embed.go @grafana/grafana-as-code /.github/workflows/feature-toggle-cleanup.yml @tolzhabayev /.github/workflows/scripts/feature-toggle-cleanup/feature-toggle-cleanup.js @tolzhabayev /.github/workflows/pr-go-workspace-check.yml @grafana/grafana-app-platform-squad +/.github/workflows/run-scenes-e2e.yml @grafana/dashboards-squad # Generated files not requiring owner approval /packages/grafana-data/src/types/featureToggles.gen.ts @grafanabot diff --git a/.github/workflows/run-scenes-e2e.yml b/.github/workflows/run-scenes-e2e.yml new file mode 100644 index 00000000000..d9082a2e38e --- /dev/null +++ b/.github/workflows/run-scenes-e2e.yml @@ -0,0 +1,41 @@ +name: Run dashboard scenes e2e + +on: + schedule: + - cron: "0 8 * * 1-5" # every day at 08:00UTC on weekdays + +env: + ARCH: linux-amd64 + +jobs: + dashboard-scenes-e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install dependencies + run: yarn install --immutable + - name: Build grafana + run: make build + - name: Install Cypress dependencies + uses: cypress-io/github-action@v6 + with: + runTests: false + - name: Run dashboard scenes e2e + run: yarn e2e:scenes + - name: "Send Slack notification" + if: ${{ failure() }} + uses: slackapi/slack-github-action@v1.26.0 + with: + payload: > + { + "icon_emoji": ":this-is-fine-fire:", + "username": "Dashboard scenes e2e tests failed", + "text": "Link to run: https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}", + "channel": "#grafana-dashboards-squad" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/e2e/cypress/support/commands.js b/e2e/cypress/support/commands.js index c1be8c4413b..0f2e21f7834 100644 --- a/e2e/cypress/support/commands.js +++ b/e2e/cypress/support/commands.js @@ -26,3 +26,9 @@ Cypress.Commands.add('startBenchmarking', (testName) => { Cypress.Commands.add('stopBenchmarking', (testName, appStats) => { return cy.task('stopBenchmarking', { testName, appStats }); }); + +Cypress.Commands.add('setLocalStorage', (key, value) => { + cy.window().then((win) => { + win.localStorage.setItem(key, value); + }); +}); diff --git a/e2e/cypress/support/e2e.js b/e2e/cypress/support/e2e.js index 695c15704cb..39bab3d3c21 100644 --- a/e2e/cypress/support/e2e.js +++ b/e2e/cypress/support/e2e.js @@ -43,3 +43,11 @@ Cypress.on('uncaught:exception', (err) => { // // failing the test // return false; // }); +// + +beforeEach(() => { + if (Cypress.env('SCENES')) { + cy.logToConsole('enabling dashboardScene feature toggle in localstorage'); + cy.setLocalStorage('grafana.featureToggles', 'dashboardScene=true'); + } +}); diff --git a/e2e/cypress/support/index.d.ts b/e2e/cypress/support/index.d.ts index a62601e97c1..c7d0acb7bcf 100644 --- a/e2e/cypress/support/index.d.ts +++ b/e2e/cypress/support/index.d.ts @@ -8,5 +8,6 @@ declare namespace Cypress { startBenchmarking(testName: string): void; stopBenchmarking(testName: string, appStats: Record): void; checkHealthRetryable(fn: Function, retryCount: number): Chainable; + setLocalStorage(key: string, value: string); } } diff --git a/e2e/run-suite b/e2e/run-suite index a607baa533b..d40abcc5fd4 100755 --- a/e2e/run-suite +++ b/e2e/run-suite @@ -27,6 +27,7 @@ declare -A env=( testFilesForSingleSuite="*.spec.ts" rootForEnterpriseSuite="./e2e/extensions-suite" +rootForScenesSuite="./e2e/scenes" declare -A cypressConfig=( [screenshotsFolder]=./e2e/"${args[0]}"/screenshots @@ -53,6 +54,7 @@ case "$1" in echo "Dev mode" CMD="cypress open" ;; + "benchmark") echo "Benchmark" PARAMS="--headed --no-runner-ui" @@ -85,6 +87,13 @@ case "$1" in ;; "") ;; + "scenes") + env[SCENES]=true + cypressConfig[specPattern]=$rootForScenesSuite/*/$testFilesForSingleSuite + cypressConfig[video]=false + # CMD="cypress open" + ;; + *) cypressConfig[specPattern]=./e2e/"${args[0]}"/$testFilesForSingleSuite cypressConfig[video]=${args[1]} diff --git a/e2e/scenes/dashboard-suite/Repeating_a_panel_horizontally.spec.ts b/e2e/scenes/dashboard-suite/Repeating_a_panel_horizontally.spec.ts new file mode 100644 index 00000000000..2f2adef207a --- /dev/null +++ b/e2e/scenes/dashboard-suite/Repeating_a_panel_horizontally.spec.ts @@ -0,0 +1,99 @@ +import { e2e } from '../utils'; +const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally'; + +describe('Repeating a panel horizontally', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('should be able to repeat a panel horizontally', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + let prevLeft = Number.NEGATIVE_INFINITY; + let prevTop: number | null = null; + const panelTitles = ['Panel Title 1', 'Panel Title 2', 'Panel Title 3']; + panelTitles.forEach((title) => { + e2e.components.Panels.Panel.title(title) + .should('be.visible') + .then(($el) => { + const { left, top } = $el[0].getBoundingClientRect(); + expect(left).to.be.greaterThan(prevLeft); + if (prevTop !== null) { + expect(top).to.be.equal(prevTop); + } + + prevLeft = left; + prevTop = top; + }); + }); + }); + + it('responds to changes to the variables', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + let prevLeft = Number.NEGATIVE_INFINITY; + let prevTop: number | null = null; + const panelTitles = ['Panel Title 1', 'Panel Title 2', 'Panel Title 3']; + panelTitles.forEach((title) => { + e2e.components.Panels.Panel.title(title).should('be.visible'); + }); + + // Change to only show panels 1 + 3 + e2e.pages.Dashboard.SubMenu.submenuItemLabels('horizontal') + .parent() + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().contains('1').click(); + e2e.components.Select.option().contains('3').click(); + + // blur the dropdown + cy.get('body').click(); + + const panelsShown = ['Panel Title 1', 'Panel Title 3']; + const panelsNotShown = ['Panel Title 2']; + panelsShown.forEach((title) => { + e2e.components.Panels.Panel.title(title) + .should('be.visible') + .then(($el) => { + const { left, top } = $el[0].getBoundingClientRect(); + expect(left).to.be.greaterThan(prevLeft); + if (prevTop !== null) { + expect(top).to.be.equal(prevTop); + } + + prevLeft = left; + prevTop = top; + }); + }); + panelsNotShown.forEach((title) => { + e2e.components.Panels.Panel.title(title).should('not.exist'); + }); + }); + + it('loads a dashboard based on the query params correctly', () => { + // Have to manually add the queryParams to the url because they have the same name + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-horizontal=1&var-horizontal=3` }); + let prevLeft = Number.NEGATIVE_INFINITY; + let prevTop: number | null = null; + const panelsShown = ['Panel Title 1', 'Panel Title 3']; + const panelsNotShown = ['Panel Title 2']; + // Check correct panels are displayed + panelsShown.forEach((title) => { + e2e.components.Panels.Panel.title(title) + .should('be.visible') + .then(($el) => { + const { left, top } = $el[0].getBoundingClientRect(); + expect(left).to.be.greaterThan(prevLeft); + if (prevTop !== null) { + expect(top).to.be.equal(prevTop); + } + + prevLeft = left; + prevTop = top; + }); + }); + panelsNotShown.forEach((title) => { + e2e.components.Panels.Panel.title(title).should('not.exist'); + }); + }); +}); diff --git a/e2e/scenes/dashboard-suite/Repeating_a_panel_vertically.spec.ts b/e2e/scenes/dashboard-suite/Repeating_a_panel_vertically.spec.ts new file mode 100644 index 00000000000..325d648bda9 --- /dev/null +++ b/e2e/scenes/dashboard-suite/Repeating_a_panel_vertically.spec.ts @@ -0,0 +1,98 @@ +import { e2e } from '../utils'; +const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically'; + +describe('Repeating a panel vertically', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('should be able to repeat a panel vertically', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + + let prevTop = Number.NEGATIVE_INFINITY; + let prevLeft: number | null = null; + const panelTitles = ['Panel Title 1', 'Panel Title 2', 'Panel Title 3']; + panelTitles.forEach((title) => { + e2e.components.Panels.Panel.title(title) + .should('be.visible') + .then(($el) => { + const { left, top } = $el[0].getBoundingClientRect(); + expect(top).to.be.greaterThan(prevTop); + if (prevLeft !== null) { + expect(left).to.be.equal(prevLeft); + } + prevLeft = left; + prevTop = top; + }); + }); + }); + + it('responds to changes to the variables', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + + let prevTop = Number.NEGATIVE_INFINITY; + let prevLeft: number | null = null; + const panelTitles = ['Panel Title 1', 'Panel Title 2', 'Panel Title 3']; + panelTitles.forEach((title) => { + e2e.components.Panels.Panel.title(title).should('be.visible'); + }); + + // Change to only show panels 1 + 3 + e2e.pages.Dashboard.SubMenu.submenuItemLabels('vertical') + .parent() + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().contains('1').click(); + e2e.components.Select.option().contains('3').click(); + + // blur the dropdown + cy.get('body').click(); + + const panelsShown = ['Panel Title 1', 'Panel Title 3']; + const panelsNotShown = ['Panel Title 2']; + panelsShown.forEach((title) => { + e2e.components.Panels.Panel.title(title) + .should('be.visible') + .then(($el) => { + const { left, top } = $el[0].getBoundingClientRect(); + expect(top).to.be.greaterThan(prevTop); + if (prevLeft !== null) { + expect(left).to.be.equal(prevLeft); + } + prevLeft = left; + prevTop = top; + }); + }); + panelsNotShown.forEach((title) => { + e2e.components.Panels.Panel.title(title).should('not.exist'); + }); + }); + + it('loads a dashboard based on the query params correctly', () => { + // Have to manually add the queryParams to the url because they have the same name + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-vertical=1&var-vertical=3` }); + + let prevTop = Number.NEGATIVE_INFINITY; + let prevLeft: number | null = null; + const panelsShown = ['Panel Title 1', 'Panel Title 3']; + const panelsNotShown = ['Panel Title 2']; + panelsShown.forEach((title) => { + e2e.components.Panels.Panel.title(title) + .should('be.visible') + .then(($el) => { + const { left, top } = $el[0].getBoundingClientRect(); + expect(top).to.be.greaterThan(prevTop); + if (prevLeft !== null) { + expect(left).to.be.equal(prevLeft); + } + prevLeft = left; + prevTop = top; + }); + }); + panelsNotShown.forEach((title) => { + e2e.components.Panels.Panel.title(title).should('not.exist'); + }); + }); +}); diff --git a/e2e/scenes/dashboard-suite/Repeating_an_empty_row.spec.ts b/e2e/scenes/dashboard-suite/Repeating_an_empty_row.spec.ts new file mode 100644 index 00000000000..9048c5149b0 --- /dev/null +++ b/e2e/scenes/dashboard-suite/Repeating_an_empty_row.spec.ts @@ -0,0 +1,85 @@ +import { e2e } from '../utils'; +const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row'; + +describe.skip('Repeating empty rows', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('should be able to repeat empty rows vertically', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + + let prevTop = Number.NEGATIVE_INFINITY; + const rowTitles = ['Row title 1', 'Row title 2', 'Row title 3']; + rowTitles.forEach((title) => { + e2e.components.DashboardRow.title(title) + .should('be.visible') + .then(($el) => { + const { top } = $el[0].getBoundingClientRect(); + expect(top).to.be.greaterThan(prevTop); + prevTop = top; + }); + }); + }); + + it('responds to changes to the variables', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + + let prevTop = Number.NEGATIVE_INFINITY; + const rowTitles = ['Row title 1', 'Row title 2', 'Row title 3']; + + rowTitles.forEach((title) => { + e2e.components.DashboardRow.title(title).should('be.visible'); + }); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('row') + .parent() + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().contains('1').click(); + e2e.components.Select.option().contains('3').click(); + + // blur the dropdown + cy.get('body').click(); + + const rowsShown = ['Row title 1', 'Row title 3']; + const rowsNotShown = ['Row title 2']; + rowsShown.forEach((title) => { + e2e.components.DashboardRow.title(title) + .should('be.visible') + .then(($el) => { + const { top } = $el[0].getBoundingClientRect(); + expect(top).to.be.greaterThan(prevTop); + prevTop = top; + }); + }); + + rowsNotShown.forEach((title) => { + e2e.components.DashboardRow.title(title).should('not.exist'); + }); + }); + + it('loads a dashboard based on the query params correctly', () => { + // Have to manually add the queryParams to the url because they have the same name + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?var-row=1&var-row=3` }); + + let prevTop = Number.NEGATIVE_INFINITY; + const rowsShown = ['Row title 1', 'Row title 3']; + const rowsNotShown = ['Row title 2']; + rowsShown.forEach((title) => { + e2e.components.DashboardRow.title(title) + .should('be.visible') + .then(($el) => { + const { top } = $el[0].getBoundingClientRect(); + expect(top).to.be.greaterThan(prevTop); + prevTop = top; + }); + }); + + rowsNotShown.forEach((title) => { + e2e.components.DashboardRow.title(title).should('not.exist'); + }); + }); +}); diff --git a/e2e/scenes/dashboard-suite/dashboard-browse-nested.spec.ts b/e2e/scenes/dashboard-suite/dashboard-browse-nested.spec.ts new file mode 100644 index 00000000000..a18c4108e4e --- /dev/null +++ b/e2e/scenes/dashboard-suite/dashboard-browse-nested.spec.ts @@ -0,0 +1,140 @@ +import { e2e } from '../utils'; + +import { makeNewDashboardRequestBody } from './utils/makeDashboard'; + +const NUM_ROOT_FOLDERS = 60; +const NUM_ROOT_DASHBOARDS = 60; +const NUM_NESTED_FOLDERS = 60; +const NUM_NESTED_DASHBOARDS = 60; + +// TODO enable this test when nested folders goes live +describe.skip('Dashboard browse (nested)', () => { + const dashboardUIDsToCleanUp: string[] = []; + const folderUIDsToCleanUp: string[] = []; + + // Add nested folder structure + before(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), false); + + // Add root folders + for (let i = 0; i < NUM_ROOT_FOLDERS; i++) { + cy.request({ + method: 'POST', + url: '/api/folders', + body: { + title: `Root folder ${i.toString().padStart(2, '0')}`, + }, + headers: { + 'Content-Type': 'application/json', + }, + }).then((response) => { + folderUIDsToCleanUp.push(response.body.uid); + }); + } + + // Add root dashboards + for (let i = 0; i < NUM_ROOT_DASHBOARDS; i++) { + cy.request({ + method: 'POST', + url: '/api/dashboards/db', + body: makeNewDashboardRequestBody(`Root dashboard ${i.toString().padStart(2, '0')}`), + headers: { + 'Content-Type': 'application/json', + }, + }).then((response) => { + dashboardUIDsToCleanUp.push(response.body.uid); + }); + } + + // Add folder with children + cy.request({ + method: 'POST', + url: '/api/folders', + body: { + title: 'A root folder with children', + }, + headers: { + 'Content-Type': 'application/json', + }, + }).then((response) => { + const folderUid = response.body.uid; + folderUIDsToCleanUp.push(folderUid); + // Add nested folders + for (let i = 0; i < NUM_NESTED_FOLDERS; i++) { + cy.request({ + method: 'POST', + url: '/api/folders', + body: { + title: `Nested folder ${i.toString().padStart(2, '0')}`, + parentUid: folderUid, + }, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + // Add nested dashboards + for (let i = 0; i < NUM_NESTED_DASHBOARDS; i++) { + cy.request({ + method: 'POST', + url: '/api/dashboards/db', + body: makeNewDashboardRequestBody(`Nested dashboard ${i.toString().padStart(2, '0')}`, folderUid), + headers: { + 'Content-Type': 'application/json', + }, + }); + } + }); + }); + + // Remove nested folder structure + after(() => { + // Clean up root dashboards + for (const dashboardUID of dashboardUIDsToCleanUp) { + e2e.flows.deleteDashboard({ + uid: dashboardUID, + quick: true, + title: '', + }); + } + // Clean up root folders (cascading delete will remove any nested folders and dashboards) + for (const folderUID of folderUIDsToCleanUp) { + cy.request({ + method: 'DELETE', + url: `/api/folders/${folderUID}`, + qs: { + forceDeleteRules: false, + }, + }); + } + }); + + it('pagination works correctly for folders and root', () => { + e2e.pages.Dashboards.visit(); + + cy.contains('A root folder with children').should('be.visible'); + + // Expand A root folder with children + cy.get('[aria-label="Expand folder A root folder with children"]').click(); + cy.contains('Nested folder 00').should('be.visible'); + + // Scroll the page and check visibility of next set of items + e2e.pages.BrowseDashboards.table.body().find('> div').scrollTo(0, 1700); + cy.contains('Nested folder 59').should('be.visible'); + cy.contains('Nested dashboard 00').should('be.visible'); + + // Scroll the page and check visibility of next set of items + e2e.pages.BrowseDashboards.table.body().find('> div').scrollTo(0, 3800); + cy.contains('Nested dashboard 59').should('be.visible'); + cy.contains('Root folder 00').should('be.visible'); + + // Scroll the page and check visibility of next set of items + e2e.pages.BrowseDashboards.table.body().find('> div').scrollTo(0, 5900); + cy.contains('Root folder 59').should('be.visible'); + cy.contains('Root dashboard 00').should('be.visible'); + + // Scroll the page and check visibility of next set of items + e2e.pages.BrowseDashboards.table.body().find('> div').scrollTo(0, 8000); + cy.contains('Root dashboard 59').should('be.visible'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/dashboard-browse.spec.ts b/e2e/scenes/dashboard-suite/dashboard-browse.spec.ts new file mode 100644 index 00000000000..4e9728b7a65 --- /dev/null +++ b/e2e/scenes/dashboard-suite/dashboard-browse.spec.ts @@ -0,0 +1,52 @@ +import testDashboard from '../dashboards/TestDashboard.json'; +import { e2e } from '../utils'; + +describe('Dashboard browse', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('Manage Dashboards tests', () => { + e2e.flows.importDashboard(testDashboard, 1000, true); + + e2e.pages.Dashboards.visit(); + + // Folders and dashboards should be visible + e2e.pages.BrowseDashboards.table.row('gdev dashboards').should('be.visible'); + e2e.pages.BrowseDashboards.table.row('E2E Test - Import Dashboard').should('be.visible'); + + // gdev dashboards folder is collapsed - its content should not be visible + e2e.pages.BrowseDashboards.table.row('Bar Gauge Demo').should('not.exist'); + + // should click a folder and see it's children + e2e.pages.BrowseDashboards.table.row('gdev dashboards').find('[aria-label^="Expand folder"]').click(); + e2e.pages.BrowseDashboards.table.row('Bar Gauge Demo').should('be.visible'); + + // Open the new folder drawer + cy.contains('button', 'New').click(); + cy.contains('button', 'New folder').click(); + + // And create a new folder + e2e.pages.BrowseDashboards.NewFolderForm.nameInput().type('My new folder'); + e2e.pages.BrowseDashboards.NewFolderForm.form().contains('button', 'Create').click(); + e2e.components.Alert.alertV2('success').find('button[aria-label="Close alert"]').click(); + cy.contains('h1', 'My new folder').should('be.visible'); + + // Delete the folder and expect to go back to the root + cy.contains('button', 'Folder actions').click(); + cy.contains('button', 'Delete').click(); + e2e.flows.confirmDelete(); + cy.contains('h1', 'Dashboards').should('be.visible'); + + // Can collapse the gdev folder and delete the dashboard we imported + e2e.pages.BrowseDashboards.table.row('gdev dashboards').find('[aria-label^="Collapse folder"]').click(); + e2e.pages.BrowseDashboards.table + .row('E2E Test - Import Dashboard') + .find('[type="checkbox"]') + .click({ force: true }); + + cy.contains('button', 'Delete').click(); + e2e.flows.confirmDelete(); + e2e.pages.BrowseDashboards.table.row('E2E Test - Import Dashboard').should('not.exist'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/dashboard-live-streaming.spec.ts b/e2e/scenes/dashboard-suite/dashboard-live-streaming.spec.ts new file mode 100644 index 00000000000..b91ccf5c270 --- /dev/null +++ b/e2e/scenes/dashboard-suite/dashboard-live-streaming.spec.ts @@ -0,0 +1,16 @@ +import testDashboard from '../dashboards/DashboardLiveTest.json'; +import { e2e } from '../utils'; + +describe('Dashboard Live streaming support', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + e2e.flows.importDashboard(testDashboard, 1000); + }); + + it('Should receive streaming data', () => { + e2e.flows.openDashboard({ uid: 'live-e2e-test' }); + cy.wait(1000); + e2e.components.Panels.Panel.title('Live').should('exist'); + e2e.components.Panels.Visualization.Table.body().find('[role="row"]').should('have.length.at.least', 5); + }); +}); diff --git a/e2e/scenes/dashboard-suite/dashboard-public-create.spec.ts b/e2e/scenes/dashboard-suite/dashboard-public-create.spec.ts new file mode 100644 index 00000000000..0f90db253f9 --- /dev/null +++ b/e2e/scenes/dashboard-suite/dashboard-public-create.spec.ts @@ -0,0 +1,153 @@ +import { e2e } from '../utils'; + +describe('Public dashboards', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('Create a public dashboard', () => { + // Opening a dashboard without template variables + cy.intercept({ + pathname: '/api/ds/query', + }).as('query'); + e2e.flows.openDashboard({ uid: 'ZqZnVvFZz' }); + cy.wait('@query'); + + // Open sharing modal + e2e.components.NavToolbar.shareDashboard().click(); + + // Select public dashboards tab + e2e.pages.ShareDashboardModal.PublicDashboardScene.Tab().click(); + + // Create button should be disabled + e2e.pages.ShareDashboardModal.PublicDashboard.CreateButton().should('be.disabled'); + + // Create flow shouldn't show these elements + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('not.exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlButton().should('not.exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.EnableAnnotationsSwitch().should('not.exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.EnableTimeRangeSwitch().should('not.exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('not.exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.DeleteButton().should('not.exist'); + + // Acknowledge checkboxes + e2e.pages.ShareDashboardModal.PublicDashboard.WillBePublicCheckbox().should('be.enabled').click({ force: true }); + e2e.pages.ShareDashboardModal.PublicDashboard.LimitedDSCheckbox().should('be.enabled').click({ force: true }); + e2e.pages.ShareDashboardModal.PublicDashboard.CostIncreaseCheckbox().should('be.enabled').click({ force: true }); + + e2e.pages.ShareDashboardModal.PublicDashboard.CreateButton().should('be.enabled'); + + // Create public dashboard + cy.intercept('POST', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('save'); + e2e.pages.ShareDashboardModal.PublicDashboard.CreateButton().click(); + cy.wait('@save'); + + // These elements shouldn't be rendered after creating public dashboard + e2e.pages.ShareDashboardModal.PublicDashboard.WillBePublicCheckbox().should('not.exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.LimitedDSCheckbox().should('not.exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.CostIncreaseCheckbox().should('not.exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.CreateButton().should('not.exist'); + + // These elements should be rendered + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlButton().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.DeleteButton().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.SettingsDropdown().should('exist'); + + e2e.pages.ShareDashboardModal.PublicDashboard.SettingsDropdown().click(); + // There elements should be rendered once the Settings dropdown is opened + e2e.pages.ShareDashboardModal.PublicDashboard.EnableAnnotationsSwitch().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.EnableTimeRangeSwitch().should('exist'); + }); + + it('Open a public dashboard', () => { + // Opening a dashboard without template variables + cy.intercept({ + method: 'POST', + pathname: '/api/ds/query', + }).as('query'); + e2e.flows.openDashboard({ uid: 'ZqZnVvFZz' }); + cy.wait('@query'); + + // Tag indicating a dashboard is public + e2e.pages.Dashboard.DashNav.publicDashboardTag().should('exist'); + + // Open sharing modal + e2e.components.NavToolbar.shareDashboard().click(); + + // Select public dashboards tab + cy.intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); + e2e.pages.ShareDashboardModal.PublicDashboardScene.Tab().click(); + cy.wait('@query-public-dashboard'); + + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlButton().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.DeleteButton().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.SettingsDropdown().should('exist'); + + e2e.pages.ShareDashboardModal.PublicDashboard.SettingsDropdown().click(); + // There elements should be rendered once the Settings dropdown is opened + e2e.pages.ShareDashboardModal.PublicDashboard.EnableTimeRangeSwitch().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.EnableAnnotationsSwitch().should('exist'); + + // Make a request to public dashboards api endpoint without authentication + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput() + .invoke('val') + .then((url) => { + cy.clearCookies() + .request(getPublicDashboardAPIUrl(String(url))) + .then((resp) => { + expect(resp.status).to.eq(200); + }); + }); + }); + + it('Disable a public dashboard', () => { + // Opening a dashboard without template variables + cy.intercept({ + method: 'POST', + pathname: '/api/ds/query', + }).as('query'); + e2e.flows.openDashboard({ uid: 'ZqZnVvFZz' }); + cy.wait('@query'); + + // Open sharing modal + e2e.components.NavToolbar.shareDashboard().click(); + + // Select public dashboards tab + cy.intercept('GET', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards').as('query-public-dashboard'); + e2e.pages.ShareDashboardModal.PublicDashboardScene.Tab().click(); + cy.wait('@query-public-dashboard'); + + // save url before disabling public dashboard + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput() + .invoke('val') + .then((text) => cy.wrap(text).as('url')); + + // Save public dashboard + cy.intercept('PATCH', '/api/dashboards/uid/ZqZnVvFZz/public-dashboards/*').as('update'); + // Switch off enabling toggle + e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('be.enabled').click({ force: true }); + cy.wait('@update'); + + // Url should be hidden + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput().should('be.disabled'); + e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlButton().should('be.disabled'); + + // Make a request to public dashboards api endpoint without authentication + cy.get('@url').then((url) => { + cy.clearCookies() + .request({ url: getPublicDashboardAPIUrl(String(url)), failOnStatusCode: false }) + .then((resp) => { + expect(resp.status).to.eq(403); + }); + }); + }); +}); + +const getPublicDashboardAPIUrl = (url: string): string => { + let accessToken = url.split('/').pop(); + return `/api/public/dashboards/${accessToken}`; +}; diff --git a/e2e/scenes/dashboard-suite/dashboard-public-templating.spec.ts b/e2e/scenes/dashboard-suite/dashboard-public-templating.spec.ts new file mode 100644 index 00000000000..4a1949a7fe7 --- /dev/null +++ b/e2e/scenes/dashboard-suite/dashboard-public-templating.spec.ts @@ -0,0 +1,29 @@ +import { e2e } from '../utils'; + +describe('Create a public dashboard with template variables shows a template variable warning', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('Create a public dashboard with template variables shows a template variable warning', () => { + // Opening a dashboard with template variables + e2e.flows.openDashboard({ uid: 'HYaGDGIMk' }); + + // Open sharing modal + e2e.components.NavToolbar.shareDashboard().click(); + + // Select public dashboards tab + e2e.pages.ShareDashboardModal.PublicDashboardScene.Tab().click(); + + // Warning Alert dashboard cannot be made public because it has template variables + e2e.pages.ShareDashboardModal.PublicDashboard.TemplateVariablesWarningAlert().should('be.visible'); + + // Configuration elements for public dashboards should not exist + e2e.pages.ShareDashboardModal.PublicDashboard.WillBePublicCheckbox().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.LimitedDSCheckbox().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.CostIncreaseCheckbox().should('exist'); + e2e.pages.ShareDashboardModal.PublicDashboard.CreateButton().should('exist'); + + e2e.pages.ShareDashboardModal.PublicDashboard.PauseSwitch().should('not.exist'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/dashboard-templating.spec.ts b/e2e/scenes/dashboard-suite/dashboard-templating.spec.ts new file mode 100644 index 00000000000..4e885df1258 --- /dev/null +++ b/e2e/scenes/dashboard-suite/dashboard-templating.spec.ts @@ -0,0 +1,60 @@ +import { e2e } from '../utils'; + +describe('Dashboard templating', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('Verify variable interpolation works', () => { + // Open dashboard global variables and interpolation + e2e.flows.openDashboard({ uid: 'HYaGDGIMk' }); + + const items: string[] = []; + const expectedItems: string[] = [ + '__dashboard = Templating - Global variables and interpolation', + '__dashboard.name = Templating - Global variables and interpolation', + '__dashboard.uid = HYaGDGIMk', + '__org.name = Main Org.', + '__org.id = 1', + '__user.id = 1', + '__user.login = admin', + '__user.email = admin@localhost', + `Server:raw = A'A"A,BB\\B,CCC`, + `Server:regex = (A'A"A|BB\\\\B|CCC)`, + `Server:lucene = ("A'A\\"A" OR "BB\\\\B" OR "CCC")`, + `Server:glob = {A'A"A,BB\\B,CCC}`, + `Server:pipe = A'A"A|BB\\B|CCC`, + `Server:distributed = A'A"A,Server=BB\\B,Server=CCC`, + `Server:csv = A'A"A,BB\\B,CCC`, + `Server:html = A'A"A, BB\\B, CCC`, + `Server:json = ["A'A\\"A","BB\\\\B","CCC"]`, + `Server:percentencode = %7BA%27A%22A%2CBB%5CB%2CCCC%7D`, + `Server:singlequote = 'A\\'A"A','BB\\B','CCC'`, + `Server:doublequote = "A'A\\"A","BB\\B","CCC"`, + `Server:sqlstring = 'A''A\\"A','BB\\\B','CCC'`, + `Server:date = NaN`, + `Server:text = All`, + `Server:queryparam = var-Server=A%27A%22A&var-Server=BB%5CB&var-Server=CCC`, + `1 < 2`, + `Example: from=now-6h&to=now`, + ]; + + cy.get('.markdown-html li') + .should('have.length', 26) + .each((element) => { + items.push(element.text()); + }) + .then(() => { + expectedItems.forEach((expected, index) => { + expect(items[index]).to.equal(expected); + }); + }); + + // Check link interpolation is working correctly + cy.contains('a', 'Example: from=now-6h&to=now').should( + 'have.attr', + 'href', + 'https://example.com/?from=now-6h&to=now' + ); + }); +}); diff --git a/e2e/scenes/dashboard-suite/dashboard-time-zone.spec.ts b/e2e/scenes/dashboard-suite/dashboard-time-zone.spec.ts new file mode 100644 index 00000000000..0361e84a236 --- /dev/null +++ b/e2e/scenes/dashboard-suite/dashboard-time-zone.spec.ts @@ -0,0 +1,263 @@ +import { + addDays, + addHours, + differenceInCalendarDays, + differenceInMinutes, + format, + isBefore, + parseISO, + toDate, +} from 'date-fns'; + +import { e2e } from '../utils'; + +describe('Dashboard time zone support', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it.skip('Tests dashboard time zone scenarios', () => { + e2e.flows.openDashboard({ uid: '5SdHCasdf' }); + + const fromTimeZone = 'UTC'; + const toTimeZone = 'America/Chicago'; + const offset = offsetBetweenTimeZones(toTimeZone, fromTimeZone); + + const panelsToCheck = [ + 'Random walk series', + 'Millisecond res x-axis and tooltip', + '2 yaxis and axis labels', + 'Stacking value ontop of nulls', + 'Null between points', + 'Legend Table No Scroll Visible', + ]; + + const timesInUtc: Record = {}; + + for (const title of panelsToCheck) { + e2e.components.Panels.Panel.title(title) + .should('be.visible') + .within(() => { + e2e.components.Panels.Visualization.Graph.xAxis.labels().should('be.visible'); + e2e.components.Panels.Visualization.Graph.xAxis + .labels() + .last() + .should((element) => { + timesInUtc[title] = element.text(); + }); + }); + } + + e2e.components.PageToolbar.item('Dashboard settings').click(); + + e2e.components.TimeZonePicker.containerV2() + .should('be.visible') + .within(() => { + e2e.components.Select.singleValue().should('have.text', 'Coordinated Universal Time'); + e2e.components.Select.input().should('be.visible').click(); + }); + + e2e.components.Select.option().should('be.visible').contains(toTimeZone).click(); + + // click to go back to the dashboard. + e2e.pages.Dashboard.Settings.Actions.close().click(); + e2e.components.RefreshPicker.runButtonV2().should('be.visible').click(); + + for (const title of panelsToCheck) { + e2e.components.Panels.Panel.title(title) + .should('be.visible') + .within(() => { + e2e.components.Panels.Visualization.Graph.xAxis.labels().should('be.visible'); + e2e.components.Panels.Visualization.Graph.xAxis + .labels() + .last() + .should((element) => { + const inUtc = timesInUtc[title]; + const inTz = element.text(); + const isCorrect = isTimeCorrect(inUtc, inTz, offset); + expect(isCorrect).to.be.equal(true); + }); + }); + } + }); + + // TODO: remove skip once https://github.com/grafana/grafana/issues/86420 is done + it.skip('Tests relative timezone support and overrides', () => { + // Open dashboard + e2e.flows.openDashboard({ + uid: 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8', + }); + + cy.intercept('/api/ds/query*').as('dataQuery'); + + // Switch to Browser timezone + e2e.flows.setTimeRange({ + from: 'now-6h', + to: 'now', + zone: 'Browser', + }); + // Need to wait for 2 calls as there's 2 panels + cy.wait(['@dataQuery', '@dataQuery']); + + e2e.components.Panels.Panel.title('Panel with relative time override') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + // Today so far, still in Browser timezone + e2e.flows.setTimeRange({ + from: 'now/d', + to: 'now', + }); + // Need to wait for 2 calls as there's 2 panels + cy.wait(['@dataQuery', '@dataQuery']); + + e2e.components.Panels.Panel.title('Panel with relative time override') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + e2e.components.Panels.Panel.title('Panel in timezone') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + // Test UTC timezone + e2e.flows.setTimeRange({ + from: 'now-6h', + to: 'now', + zone: 'Coordinated Universal Time', + }); + // Need to wait for 2 calls as there's 2 panels + cy.wait(['@dataQuery', '@dataQuery']); + + e2e.components.Panels.Panel.title('Panel with relative time override') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + // Today so far, still in UTC timezone + e2e.flows.setTimeRange({ + from: 'now/d', + to: 'now', + }); + // Need to wait for 2 calls as there's 2 panels + cy.wait(['@dataQuery', '@dataQuery']); + + e2e.components.Panels.Panel.title('Panel with relative time override') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + e2e.components.Panels.Panel.title('Panel in timezone') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + // Test Tokyo timezone + e2e.flows.setTimeRange({ + from: 'now-6h', + to: 'now', + zone: 'Asia/Tokyo', + }); + // Need to wait for 2 calls as there's 2 panels + cy.wait(['@dataQuery', '@dataQuery']); + + e2e.components.Panels.Panel.title('Panel with relative time override') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + // Today so far, still in Tokyo timezone + e2e.flows.setTimeRange({ + from: 'now/d', + to: 'now', + }); + // Need to wait for 2 calls as there's 2 panels + cy.wait(['@dataQuery', '@dataQuery']); + + e2e.components.Panels.Panel.title('Panel with relative time override') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + e2e.components.Panels.Panel.title('Panel in timezone') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + // Test LA timezone + e2e.flows.setTimeRange({ + from: 'now-6h', + to: 'now', + zone: 'America/Los_Angeles', + }); + // Need to wait for 2 calls as there's 2 panels + cy.wait(['@dataQuery', '@dataQuery']); + + e2e.components.Panels.Panel.title('Panel with relative time override') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + // Today so far, still in LA timezone + e2e.flows.setTimeRange({ + from: 'now/d', + to: 'now', + }); + // Need to wait for 2 calls as there's 2 panels + cy.wait(['@dataQuery', '@dataQuery']); + + e2e.components.Panels.Panel.title('Panel with relative time override') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + + e2e.components.Panels.Panel.title('Panel in timezone') + .should('be.visible') + .within(() => { + cy.contains('[role="row"]', '00:00:00').should('be.visible'); + }); + }); +}); + +const isTimeCorrect = (inUtc: string, inTz: string, offset: number): boolean => { + if (inUtc === inTz) { + // we need to catch issues when timezone isn't changed for some reason like https://github.com/grafana/grafana/issues/35504 + return false; + } + + const reference = format(new Date(), 'yyyy-LL-dd'); + + const utcDate = toDate(parseISO(`${reference} ${inUtc}`)); + const utcDateWithOffset = addHours(toDate(parseISO(`${reference} ${inUtc}`)), offset); + const dayDifference = differenceInCalendarDays(utcDate, utcDateWithOffset); // if the utcDate +/- offset is the day before/after then we need to adjust reference + const dayOffset = isBefore(utcDateWithOffset, utcDate) ? dayDifference * -1 : dayDifference; + const tzDate = addDays(toDate(parseISO(`${reference} ${inTz}`)), dayOffset); // adjust tzDate with any dayOffset + const diff = Math.abs(differenceInMinutes(utcDate, tzDate)); // use Math.abs if tzDate is in future + + return diff <= Math.abs(offset * 60); +}; + +const offsetBetweenTimeZones = (timeZone1: string, timeZone2: string, when: Date = new Date()): number => { + const t1 = convertDateToAnotherTimeZone(when, timeZone1); + const t2 = convertDateToAnotherTimeZone(when, timeZone2); + return (t1.getTime() - t2.getTime()) / (1000 * 60 * 60); +}; + +const convertDateToAnotherTimeZone = (date: Date, timeZone: string): Date => { + const dateString = date.toLocaleString('en-US', { + timeZone: timeZone, + }); + return new Date(dateString); +}; diff --git a/e2e/scenes/dashboard-suite/dashboard-timepicker.spec.ts b/e2e/scenes/dashboard-suite/dashboard-timepicker.spec.ts new file mode 100644 index 00000000000..e4ffbec6299 --- /dev/null +++ b/e2e/scenes/dashboard-suite/dashboard-timepicker.spec.ts @@ -0,0 +1,46 @@ +import { e2e } from '../utils'; + +describe('Dashboard timepicker', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('Shows the correct calendar days with custom timezone set via preferences', () => { + e2e.flows.setUserPreferences({ + timezone: 'Asia/Tokyo', + }); + + // Open dashboard with time range from 8th to end of 10th. + // Will be Tokyo time because of above preference + e2e.flows.openDashboard({ + uid: '5SdHCasdf', + timeRange: { + zone: 'Default', + from: '2022-06-08 00:00:00', + to: '2022-06-10 23:59:59', + }, + }); + + // Assert that the calendar shows 08 and 09 and 10 as selected days + e2e.components.TimePicker.openButton().click(); + e2e.components.TimePicker.calendar.openButton().first().click(); + cy.get('.react-calendar__tile--active, .react-calendar__tile--hasActive').should('have.length', 3); + }); + + it('Shows the correct calendar days with custom timezone set via time picker', () => { + // Open dashboard with time range from 2022-06-08 00:00:00 to 2022-06-10 23:59:59 in Tokyo time + e2e.flows.openDashboard({ + uid: '5SdHCasdf', + timeRange: { + zone: 'Asia/Tokyo', + from: '2022-06-08 00:00:00', + to: '2022-06-10 23:59:59', + }, + }); + + // Assert that the calendar shows 08 and 09 and 10 as selected days + e2e.components.TimePicker.openButton().click(); + e2e.components.TimePicker.calendar.openButton().first().click(); + cy.get('.react-calendar__tile--active, .react-calendar__tile--hasActive').should('have.length', 3); + }); +}); diff --git a/e2e/scenes/dashboard-suite/embedded-dashboard.spec.ts b/e2e/scenes/dashboard-suite/embedded-dashboard.spec.ts new file mode 100644 index 00000000000..8c52271994c --- /dev/null +++ b/e2e/scenes/dashboard-suite/embedded-dashboard.spec.ts @@ -0,0 +1,24 @@ +import { selectors } from '@grafana/e2e-selectors'; + +import { e2e } from '../utils'; +import { fromBaseUrl } from '../utils/support/url'; + +describe('Embedded dashboard', function () { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('open test page', function () { + cy.visit(fromBaseUrl('/dashboards/embedding-test')); + + // Verify pie charts are rendered + cy.get( + `[data-viz-panel-key="panel-11"] [data-testid^="${selectors.components.Panels.Visualization.PieChart.svgSlice}"]` + ).should('have.length', 5); + + // Verify no url sync + e2e.components.TimePicker.openButton().click(); + cy.get('label:contains("Last 1 hour")').click(); + cy.url().should('eq', fromBaseUrl('/dashboards/embedding-test')); + }); +}); diff --git a/e2e/scenes/dashboard-suite/general-dashboards.spec.ts b/e2e/scenes/dashboard-suite/general-dashboards.spec.ts new file mode 100644 index 00000000000..8270a7b1e5e --- /dev/null +++ b/e2e/scenes/dashboard-suite/general-dashboards.spec.ts @@ -0,0 +1,35 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; + +describe('Dashboards', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('should restore scroll position', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + e2e.components.Panels.Panel.title('Panel #1').should('be.visible'); + + // scroll to the bottom + e2e.pages.Dashboard.DashNav.scrollContainer() + .children() + .first() + .scrollTo('bottom', { + timeout: 5 * 1000, + }); + + // The last panel should be visible... + e2e.components.Panels.Panel.title('Panel #50').should('be.visible'); + + // Then we open and close the panel editor + e2e.components.Panels.Panel.menu('Panel #50').click({ force: true }); // it only shows on hover + e2e.components.Panels.Panel.menuItems('Edit').click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + + // And the last panel should still be visible! + // TODO: investigate scroll to on navigating back + // e2e.components.Panels.Panel.title('Panel #50').should('be.visible'); + e2e.components.Panels.Panel.title('Panel #1').should('be.visible'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/import-dashboard.spec.ts b/e2e/scenes/dashboard-suite/import-dashboard.spec.ts new file mode 100644 index 00000000000..6d4bfed5e68 --- /dev/null +++ b/e2e/scenes/dashboard-suite/import-dashboard.spec.ts @@ -0,0 +1,12 @@ +import testDashboard from '../dashboards/TestDashboard.json'; +import { e2e } from '../utils'; + +describe('Import Dashboards Test', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('Ensure you can import a number of json test dashboards from a specific test directory', () => { + e2e.flows.importDashboard(testDashboard, 1000); + }); +}); diff --git a/e2e/scenes/dashboard-suite/load-options-from-url.spec.ts b/e2e/scenes/dashboard-suite/load-options-from-url.spec.ts new file mode 100644 index 00000000000..3d87b896947 --- /dev/null +++ b/e2e/scenes/dashboard-suite/load-options-from-url.spec.ts @@ -0,0 +1,203 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; + +describe('Variables - Load options from Url', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('default options should be correct', () => { + e2e.flows.openDashboard({ uid: PAGE_UNDER_TEST }); + cy.intercept({ + method: 'POST', + pathname: '/api/ds/query*', + }).as('query'); + + cy.wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A') + .should('be.visible') + .children() + .children() + .first() + .click(); + + e2e.components.Select.option().parent().should('have.length', 8); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'B') + .next() + .should('have.text', 'C') + .next() + .should('have.text', 'D'); + + cy.get('body').click(0, 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA') + .should('be.visible') + .children() + .children() + .first() + .click(); + + e2e.components.Select.option().parent().should('have.length', 8); + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'AB') + .next() + .should('have.text', 'AC') + .next() + .should('have.text', 'AD'); + + cy.get('body').click(0, 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('$__all') + .should('be.visible') + .children() + .children() + .first() + .click(); + + e2e.components.Select.option().parent().should('have.length', 8); + + e2e.components.Select.option() + .first() + .should('have.text', 'AAA') + .parent() + .next() + .should('have.text', 'AAB') + .next() + .should('have.text', 'AAC') + .next() + .should('have.text', 'AAD'); + }); + + it('options set in url should load correct options', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=B&var-server=BB&var-pod=BBB` }); + cy.intercept({ + method: 'POST', + pathname: '/api/ds/query', + }).as('query'); + + cy.wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B') + .should('be.visible') + .children() + .children() + .first() + .click(); + + e2e.components.Select.option().parent().should('have.length', 8); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'A') + .next() + .should('have.text', 'C') + .next() + .should('have.text', 'D'); + + cy.get('body').click(0, 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BB') + .should('be.visible') + .children() + .children() + .first() + .click(); + + e2e.components.Select.option().parent().should('have.length', 8); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'BA') + .next() + .should('have.text', 'BC') + .next() + .should('have.text', 'BD'); + + cy.get('body').click(0, 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BBB') + .should('be.visible') + .children() + .children() + .first() + .click(); + + e2e.components.Select.option().parent().should('have.length', 8); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'BBA') + .next() + .should('have.text', 'BBC') + .next() + .should('have.text', 'BBD'); + }); + + it('options set in url that do not exist should load correct options', () => { + // @ts-ignore some typing issue + cy.on('uncaught:exception', (err) => { + if (err.stack?.indexOf("Couldn't find any field of type string in the results.") !== -1) { + // return false to prevent the error from + // failing this test + return false; + } + + return true; + }); + + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=X` }); + cy.intercept({ + method: 'POST', + pathname: '/api/ds/query', + }).as('query'); + + cy.wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('X') + .should('be.visible') + .children() + .children() + .first() + .click(); + + e2e.components.Select.option().parent().should('have.length', 9); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'A') + .next() + .should('have.text', 'B') + .next() + .should('have.text', 'C'); + + cy.get('body').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('$__all') + .should('be.visible') + .should('have.length', 2); + }); +}); diff --git a/e2e/scenes/dashboard-suite/new-constant-variable.spec.ts b/e2e/scenes/dashboard-suite/new-constant-variable.spec.ts new file mode 100644 index 00000000000..42693acb668 --- /dev/null +++ b/e2e/scenes/dashboard-suite/new-constant-variable.spec.ts @@ -0,0 +1,38 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; + +describe('Variables - Constant', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('can add a new constant variable', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + // Create a new "Constant" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().within(() => { + cy.get('input').type('Constant{enter}'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type('VariableUnderTest').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInputV2().type('pesto').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type('Variable under test').blur(); + + // e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(0).should('have.text', 'pesto'); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.components.RefreshPicker.runButtonV2().click(); + + // Assert it was rendered + cy.get('.markdown-html').should('include.text', 'VariableUnderTest: pesto'); + + // Assert the variable is not visible in the dashboard nav + e2e.pages.Dashboard.SubMenu.submenuItemLabels('Variable under test').should('not.exist'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/new-custom-variable.spec.ts b/e2e/scenes/dashboard-suite/new-custom-variable.spec.ts new file mode 100644 index 00000000000..bda748fbdf2 --- /dev/null +++ b/e2e/scenes/dashboard-suite/new-custom-variable.spec.ts @@ -0,0 +1,68 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; + +function fillInCustomVariable(name: string, label: string, value: string) { + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().within(() => { + cy.get('input').type('Custom{enter}'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(name).blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type(label).blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput().type(value).blur(); +} + +function assertPreviewValues(expectedValues: string[]) { + for (const expected of expectedValues) { + const index = expectedValues.indexOf(expected); + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(index).should('have.text', expected); + } +} + +describe('Variables - Custom', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('can add a custom template variable', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + // Create a new "Custom" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + fillInCustomVariable('VariableUnderTest', 'Variable under test', 'one,two,three'); + assertPreviewValues(['one', 'two', 'three']); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('one').click(); + e2e.components.Select.option().contains('two').click(); + // Assert it was rendered + cy.get('.markdown-html').should('include.text', 'VariableUnderTest: two'); + }); + + it('can add a custom template variable with labels', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + // Create a new "Custom" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().within(() => { + cy.get('input').type('Custom{enter}'); + }); + + // Set its name, label, and content + fillInCustomVariable('VariableUnderTest', 'Variable under test', 'One : 1,Two : 2, Three : 3'); + assertPreviewValues(['One', 'Two', 'Three']); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('1').click(); + e2e.components.Select.option().contains('Two').click(); + + // Assert it was rendered + cy.get('.markdown-html').should('include.text', 'VariableUnderTest: 2'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/new-datasource-variable.spec.ts b/e2e/scenes/dashboard-suite/new-datasource-variable.spec.ts new file mode 100644 index 00000000000..3b325c17581 --- /dev/null +++ b/e2e/scenes/dashboard-suite/new-datasource-variable.spec.ts @@ -0,0 +1,49 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; + +describe('Variables - Datasource', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('can add a new datasource variable', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + // Create a new "Datasource" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().within(() => { + cy.get('input').type('Data source{enter}'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type('VariableUnderTest').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type('Variable under test').blur(); + + // If this is failing, but sure to check there are Prometheus datasources named "gdev-prometheus" and "gdev-slow-prometheus" + // Or, just update is to match some gdev datasources to test with :) + e2e.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect().within(() => { + cy.get('input').type('Prometheus{enter}'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should( + 'contain.text', + 'gdev-prometheus' + ); + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should( + 'contain.text', + 'gdev-slow-prometheus' + ); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.components.RefreshPicker.runButtonV2().click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('gdev-prometheus').click(); + e2e.components.Select.option().contains('gdev-slow-prometheus').click(); + + // Assert it was rendered + cy.get('.markdown-html').should('include.text', 'VariableUnderTest: gdev-slow-prometheus-uid'); + cy.get('.markdown-html').should('include.text', 'VariableUnderTestText: gdev-slow-prometheus'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/new-interval-variable.spec.ts b/e2e/scenes/dashboard-suite/new-interval-variable.spec.ts new file mode 100644 index 00000000000..6ac5fe1f5b4 --- /dev/null +++ b/e2e/scenes/dashboard-suite/new-interval-variable.spec.ts @@ -0,0 +1,50 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; + +function assertPreviewValues(expectedValues: string[]) { + for (const expected of expectedValues) { + const index = expectedValues.indexOf(expected); + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().eq(index).should('have.text', expected); + } +} + +describe('Variables - Interval', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + // TODO: remove skip once https://github.com/grafana/grafana/issues/84727 is done + it.skip('can add a new interval variable', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + // Create a new "Interval" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().within(() => { + cy.get('input').type('Interval{enter}'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type('VariableUnderTest').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type('Variable under test').blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.IntervalVariable.intervalsValueInput() + .clear() + .type('10s,10m,60m,90m,1h30m') + .blur(); + + assertPreviewValues(['10s', '10m', '60m', '90m', '1h30m']); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + + e2e.components.RefreshPicker.runButtonV2().click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('10s').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('1h30m').click(); + + // Assert it was rendered + cy.get('.markdown-html').should('include.text', 'VariableUnderTest: 1h30m'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/new-query-variable.spec.ts b/e2e/scenes/dashboard-suite/new-query-variable.spec.ts new file mode 100644 index 00000000000..c57fa011d3a --- /dev/null +++ b/e2e/scenes/dashboard-suite/new-query-variable.spec.ts @@ -0,0 +1,181 @@ +import { selectors } from '@grafana/e2e-selectors'; + +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; +const DASHBOARD_NAME = 'Templating - Nested Template Variables'; + +describe('Variables - Query - Add variable', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('query variable should be default and default fields should be correct', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + cy.get(`[data-testid="${selectors.pages.Dashboard.Settings.Variables.List.newButton}"]`) + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2() + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('Variable name'); + expect(input.val()).equals('query0'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', 'Query'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2() + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('Label name'); + expect(input.val()).equals(''); + }); + cy.get('[placeholder="Descriptive text"]') + .should('be.visible') + .within((input) => { + expect(input.attr('placeholder')).equals('Descriptive text'); + expect(input.val()).equals(''); + }); + cy.get('label').contains('Show on dashboard').should('be.visible'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .get('input[placeholder="gdev-testdata"]') + .scrollIntoView() + .should('be.visible'); + + cy.get('label').contains('Refresh').scrollIntoView().should('be.visible'); + cy.get('label').contains('On dashboard load').scrollIntoView().should('be.visible'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2() + .should('be.visible') + .within((input) => { + const placeholder = '/.*-(?.*)-(?.*)-.*/'; + expect(input.attr('placeholder')).equals(placeholder); + expect(input.val()).equals(''); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2() + .should('be.visible') + .within((select) => { + e2e.components.Select.singleValue().should('have.text', 'Disabled'); + }); + + cy.contains('label', 'Multi-value').within(() => { + cy.get('input[type="checkbox"]').should('not.be.checked'); + }); + + cy.contains('label', 'Include All option').within(() => { + cy.get('input[type="checkbox"]').should('not.be.checked'); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('not.have.text'); + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().should('not.exist'); + }); + + it('adding a single value query variable', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + cy.get(`[data-testid="${selectors.pages.Dashboard.Settings.Variables.List.newButton}"]`) + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2() + .should('be.visible') + .clear() + .type('a label'); + + cy.get('[placeholder="Descriptive text"]').should('be.visible').clear().type('a description'); + + e2e.components.DataSourcePicker.container().should('be.visible').type('gdev-testdata{enter}'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput() + .should('be.visible') + .type('*') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2() + .should('be.visible') + .type('/.*C.*/') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('exist'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().scrollIntoView().should('be.visible').click(); + + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('have.length', 4) + .eq(3) + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().should('have.length', 1); + e2e.components.Select.option().contains('C'); + }); + + it('adding a multi value query variable', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + cy.get(`[data-testid="${selectors.pages.Dashboard.Settings.Variables.List.newButton}"]`) + .should('be.visible') + .click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2() + .should('be.visible') + .clear() + .type('a label'); + + cy.get('[placeholder="Descriptive text"]').should('be.visible').clear().type('a description'); + + e2e.components.DataSourcePicker.container().type('gdev-testdata{enter}'); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput() + .should('be.visible') + .type('*') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2() + .should('be.visible') + .type('/.*C.*/') + .blur(); + + cy.contains('label', 'Multi-value').within(() => { + cy.get('input[type="checkbox"]').click({ force: true }).should('be.checked'); + }); + + cy.contains('label', 'Include All option').within(() => { + cy.get('input[type="checkbox"]').click({ force: true }).should('be.checked'); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().within((input) => { + expect(input.attr('placeholder')).equals('blank = auto'); + expect(input.val()).equals(''); + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('exist'); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().scrollIntoView().should('be.visible').click(); + + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('a label').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('have.length', 4) + .eq(3) + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().should('have.length', 1); + e2e.components.Select.option().contains('All'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/new-text-box-variable.spec.ts b/e2e/scenes/dashboard-suite/new-text-box-variable.spec.ts new file mode 100644 index 00000000000..7da9afa32bf --- /dev/null +++ b/e2e/scenes/dashboard-suite/new-text-box-variable.spec.ts @@ -0,0 +1,34 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; +const DASHBOARD_NAME = 'Test variable output'; + +describe('Variables - Text box', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('can add a new text box variable', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&editview=variables` }); + cy.contains(DASHBOARD_NAME).should('be.visible'); + + // Create a new "text box" variable + e2e.components.CallToActionCard.buttonV2('Add variable').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().within(() => { + cy.get('input').type('Textbox{enter}'); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type('VariableUnderTest').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type('Variable under test').blur(); + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInputV2().type('cat-dog').blur(); + + // Navigate back to the homepage and change the selected variable value + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.pages.Dashboard.SubMenu.submenuItem().within(() => { + cy.get('input').clear().type('dog-cat').blur(); + }); + // Assert it was rendered + cy.get('.markdown-html').should('include.text', 'VariableUnderTest: dog-cat'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/set-options-from-ui.spec.ts b/e2e/scenes/dashboard-suite/set-options-from-ui.spec.ts new file mode 100644 index 00000000000..ac0775f966b --- /dev/null +++ b/e2e/scenes/dashboard-suite/set-options-from-ui.spec.ts @@ -0,0 +1,190 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; + +describe('Variables - Set options from ui', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('clicking a value that is not part of dependents options should change these to All', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-server=AA&var-pod=AAA` }); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A').should('be.visible').click().click(); + e2e.components.Select.option().contains('B').click(); + cy.get('body').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B').scrollIntoView().should('be.visible'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('$__all') + .should('have.length', 2) + .eq(0) + .should('be.visible') + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().parent().should('have.length', 8); + e2e.components.Select.option() + .first() + .should('have.text', 'BA') + .parent() + .next() + .should('have.text', 'BB') + .next() + .should('have.text', 'BC'); + + cy.get('body').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('pod') + .next() + .within(() => { + cy.get('input').click(); + }); + + // length is 11 because of virtualized select options + e2e.components.Select.option().parent().should('have.length', 11); + + e2e.components.Select.option() + .first() + .should('have.text', 'BAA') + .parent() + .next() + .should('have.text', 'BAB') + .next() + .should('have.text', 'BAC') + .next() + .should('have.text', 'BAD') + .next() + .should('have.text', 'BAE') + .next() + .should('have.text', 'BAF'); + }); + + it('adding a value that is not part of dependents options should add the new values dependant options', () => { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-server=AA&var-pod=AAA` }); + cy.intercept({ + pathname: '/api/ds/query', + }).as('query'); + + cy.wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A') + .should('be.visible') + .within(() => { + cy.get('input').click(); + }); + e2e.components.Select.option().contains('B').click(); + cy.get('body').click(); + + cy.wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A,B').scrollIntoView().should('be.visible'); + + e2e.components.LoadingIndicator.icon().should('have.length', 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AA') + .should('be.visible') + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().should('have.length', 11); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'AB') + .next() + .should('have.text', 'AC') + .next() + .should('have.text', 'AD'); + + cy.get('body').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('AAA') + .should('be.visible') + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().should('have.length', 8); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'AAB') + .next() + .should('have.text', 'AAC'); + }); + + it('removing a value that is part of dependents options should remove the new values dependant options', () => { + e2e.flows.openDashboard({ + uid: `${PAGE_UNDER_TEST}?orgId=1&var-datacenter=A&var-datacenter=B&var-server=AA&var-server=BB&var-pod=AAA&var-pod=BBB`, + }); + cy.intercept({ pathname: '/api/ds/query' }).as('query'); + + cy.wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('A,B') + .should('be.visible') + .children() + .first() + .click(); + + cy.get('body').click(); + + cy.wait(300); + cy.wait('@query'); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('B') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.get('input').click(); + }); + + cy.get('body').click(); + + e2e.components.LoadingIndicator.icon().should('have.length', 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BB') + .should('be.visible') + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().should('have.length', 8); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'BA') + .next() + .should('have.text', 'BC'); + + cy.get('body').click(0, 0); + + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('BBB') + .should('be.visible') + .within(() => { + cy.get('input').click(); + }); + e2e.components.Select.option().should('have.length', 8); + + e2e.components.Select.option() + .first() + .should('have.text', 'All') + .parent() + .next() + .should('have.text', 'BBA') + .next() + .should('have.text', 'BBC'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/templating-dashboard-links-and-variables.spec.ts b/e2e/scenes/dashboard-suite/templating-dashboard-links-and-variables.spec.ts new file mode 100644 index 00000000000..87c832d45da --- /dev/null +++ b/e2e/scenes/dashboard-suite/templating-dashboard-links-and-variables.spec.ts @@ -0,0 +1,64 @@ +import { e2e } from '../utils'; + +describe('Templating', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('Tests dashboard links and variables in links', () => { + cy.intercept({ + method: 'GET', + url: '/api/search?tag=templating&limit=100', + }).as('tagsTemplatingSearch'); + cy.intercept({ + method: 'GET', + url: '/api/search?tag=demo&limit=100', + }).as('tagsDemoSearch'); + + e2e.flows.openDashboard({ uid: 'yBCC3aKGk' }); + + // waiting for network requests first + cy.wait(['@tagsTemplatingSearch', '@tagsDemoSearch']); + + const verifyLinks = (variableValue: string) => { + e2e.components.DashboardLinks.link() + .should('be.visible') + .should((links) => { + expect(links).to.have.length.greaterThan(13); + + for (let index = 0; index < links.length; index++) { + expect(Cypress.$(links[index]).attr('href')).contains(variableValue); + } + }); + }; + + e2e.components.DashboardLinks.dropDown().should('be.visible').click().wait('@tagsTemplatingSearch'); + + // verify all links, should have All value + verifyLinks('var-custom=p1&var-custom=p2&var-custom=p3'); + + cy.get('body').click(); + + e2e.pages.Dashboard.SubMenu.submenuItemLabels('custom') + .next() + .should('have.text', 'All') + .parent() + .within(() => { + cy.get('input').click(); + }); + + e2e.components.Select.option().contains('p2').click(); + + cy.get('body').click(); + + e2e.components.NavToolbar.container().click(); + e2e.components.DashboardLinks.dropDown() + .scrollIntoView() + .should('be.visible') + .click() + .wait('@tagsTemplatingSearch'); + + // verify all links, should have p2 value + verifyLinks('p2'); + }); +}); diff --git a/e2e/scenes/dashboard-suite/textbox-variables.spec.ts b/e2e/scenes/dashboard-suite/textbox-variables.spec.ts new file mode 100644 index 00000000000..f52a5b2a616 --- /dev/null +++ b/e2e/scenes/dashboard-suite/textbox-variables.spec.ts @@ -0,0 +1,237 @@ +import { e2e } from '../utils'; + +const PAGE_UNDER_TEST = 'AejrN1AMz'; + +describe('TextBox - load options scenarios', function () { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + // TODO: remove skip after https://github.com/grafana/grafana/issues/86435 + it.skip('default options should be correct', function () { + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1` }); + + validateTextboxAndMarkup('default value'); + }); + + it('loading variable from url should be correct', function () { + e2e.flows.openDashboard({ + uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1&var-text=not default value`, + }); + + validateTextboxAndMarkup('not default value'); + }); +}); + +describe.skip('TextBox - change query scenarios', function () { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('when changing the query value and not saving current as default should revert query value', function () { + copyExistingDashboard(); + + changeQueryInput(); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(false); + + cy.get('@dashuid').then((dashuid) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + cy.wait('@load-dash'); + + validateTextboxAndMarkup('default value'); + + validateVariable('changed value'); + }); + }); + + it('when changing the query value and saving current as default should change query value', function () { + copyExistingDashboard(); + + changeQueryInput(); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(true); + + cy.get('@dashuid').then((dashuid) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + cy.wait('@load-dash'); + + validateTextboxAndMarkup('changed value'); + + validateVariable('changed value'); + }); + }); +}); + +describe.skip('TextBox - change picker value scenarios', function () { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + }); + + it('when changing the input value and not saving current as default should revert query value', function () { + copyExistingDashboard(); + + changeTextBoxInput(); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(false); + + cy.get('@dashuid').then((dashuid) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + cy.wait('@load-dash'); + + validateTextboxAndMarkup('default value'); + validateVariable('default value'); + }); + }); + + it('when changing the input value and saving current as default should change query value', function () { + copyExistingDashboard(); + + changeTextBoxInput(); + + validateTextboxAndMarkup('changed value'); + + saveDashboard(true); + + cy.get('@dashuid').then((dashuid) => { + expect(dashuid).not.to.eq(PAGE_UNDER_TEST); + + e2e.flows.openDashboard({ uid: dashuid }); + + cy.wait('@load-dash'); + + validateTextboxAndMarkup('changed value'); + validateVariable('changed value'); + }); + }); +}); + +function copyExistingDashboard() { + cy.intercept({ + method: 'GET', + url: '/api/search?query=&type=dash-folder&permission=Edit', + }).as('dash-settings'); + cy.intercept({ + method: 'POST', + url: '/api/dashboards/db/', + }).as('save-dash'); + cy.intercept({ + method: 'GET', + url: /\/api\/dashboards\/uid\/(?!AejrN1AMz)\w+/, + }).as('load-dash'); + e2e.flows.openDashboard({ uid: `${PAGE_UNDER_TEST}/templating-textbox-e2e-scenarios?orgId=1&editview=settings` }); + + cy.wait('@dash-settings'); + + e2e.pages.Dashboard.Settings.General.saveAsDashBoard().should('be.visible').click(); + + e2e.pages.SaveDashboardAsModal.newName().should('be.visible').type(`${Date.now()}`); + + e2e.pages.SaveDashboardAsModal.save().should('be.visible').click(); + + cy.wait('@save-dash'); + cy.wait('@load-dash'); + + e2e.pages.Dashboard.SubMenu.submenuItem().should('be.visible'); + + cy.location().then((loc) => { + const dashuid = /\/d\/(\w+)\//.exec(loc.href)![1]; + cy.wrap(dashuid).as('dashuid'); + }); + + cy.wait(500); +} + +function saveDashboard(saveVariables: boolean) { + e2e.components.PageToolbar.item('Save dashboard').should('be.visible').click(); + + if (saveVariables) { + e2e.pages.SaveDashboardModal.saveVariables().should('exist').click({ force: true }); + } + + e2e.pages.SaveDashboardModal.save().should('be.visible').click(); + + cy.wait('@save-dash'); +} + +function validateTextboxAndMarkup(value: string) { + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('be.visible') + .within(() => { + e2e.pages.Dashboard.SubMenu.submenuItemLabels('text').should('be.visible'); + cy.get('input').should('be.visible').should('have.value', value); + }); + + e2e.components.Panels.Visualization.Text.container() + .should('be.visible') + .within(() => { + cy.get('h1').should('be.visible').should('have.text', `variable: ${value}`); + }); +} + +function validateVariable(value: string) { + e2e.components.PageToolbar.item('Dashboard settings').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.General.sectionItems('Variables').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields('text').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInputV2() + .should('be.visible') + .should('have.value', value); +} + +function changeTextBoxInput() { + e2e.pages.Dashboard.SubMenu.submenuItemLabels('text').should('be.visible'); + e2e.pages.Dashboard.SubMenu.submenuItem() + .should('be.visible') + .within(() => { + cy.get('input') + .should('be.visible') + .should('have.value', 'default value') + .clear() + .type('changed value') + .type('{enter}'); + }); + + cy.location().should((loc) => { + expect(loc.search).to.contain('var-text=changed%20value'); + }); +} + +function changeQueryInput() { + e2e.components.PageToolbar.item('Dashboard settings').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.General.sectionItems('Variables').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.List.tableRowNameFields('text').should('be.visible').click(); + + e2e.pages.Dashboard.Settings.Variables.Edit.TextBoxVariable.textBoxOptionsQueryInputV2() + .should('be.visible') + .clear() + .type('changed value') + .blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() + .should('have.length', 1) + .should('have.text', 'changed value'); +} diff --git a/e2e/scenes/dashboard-suite/utils/makeDashboard.ts b/e2e/scenes/dashboard-suite/utils/makeDashboard.ts new file mode 100644 index 00000000000..4deaaa773ce --- /dev/null +++ b/e2e/scenes/dashboard-suite/utils/makeDashboard.ts @@ -0,0 +1,58 @@ +export function makeNewDashboardRequestBody(dashboardName: string, folderUid?: string) { + return { + dashboard: { + annotations: { + list: [ + { + builtIn: 1, + datasource: { type: 'grafana', uid: '-- Grafana --' }, + enable: true, + hide: true, + iconColor: 'rgba(0, 211, 255, 1)', + name: 'Annotations & Alerts', + type: 'dashboard', + }, + ], + }, + editable: true, + fiscalYearStartMonth: 0, + graphTooltip: 0, + links: [], + liveNow: false, + panels: [ + { + datasource: { type: 'testdata', uid: '89_jzlT4k' }, + gridPos: { h: 9, w: 12, x: 0, y: 0 }, + id: 2, + options: { + code: { + language: 'plaintext', + showLineNumbers: false, + showMiniMap: false, + }, + content: '***A nice little happy empty dashboard***', + mode: 'markdown', + }, + pluginVersion: '9.4.0-pre', + title: 'Nothing to see here', + type: 'text', + }, + ], + refresh: '', + revision: 1, + schemaVersion: 38, + tags: [], + templating: { list: [] }, + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + timezone: '', + title: dashboardName, + version: 0, + weekStart: '', + uid: '', + }, + message: '', + overwrite: false, + folderUid, + } as const; +} diff --git a/e2e/scenes/dashboards/DashboardLiveTest.json b/e2e/scenes/dashboards/DashboardLiveTest.json new file mode 100644 index 00000000000..7d98377df89 --- /dev/null +++ b/e2e/scenes/dashboards/DashboardLiveTest.json @@ -0,0 +1,107 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "10.3.0-pre", + "targets": [ + { + "channel": "plugin/testdata/random-20Hz-stream", + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "queryType": "measurements", + "refId": "A" + } + ], + "title": "Live", + "type": "table" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "New dashboard", + "version": 0, + "uid": "live-e2e-test", + "weekStart": "" +} diff --git a/e2e/scenes/dashboards/DashboardSearchTest.json b/e2e/scenes/dashboards/DashboardSearchTest.json new file mode 100644 index 00000000000..da9fbcf78ca --- /dev/null +++ b/e2e/scenes/dashboards/DashboardSearchTest.json @@ -0,0 +1,209 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 321, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 6, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.3.0-pre", + "title": "Gauge Example", + "type": "gauge" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.3.0-pre", + "title": "Stat", + "type": "stat" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "title": "Time series example", + "type": "timeseries" + } + ], + "refresh": false, + "schemaVersion": 31, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "2021-09-01T04:00:00.000Z", + "to": "2021-09-15T04:00:00.000Z" + }, + "timepicker": {}, + "timezone": "", + "title": "E2E Test - Dashboard Search", + "uid": "kquZN5H7k", + "version": 4 +} diff --git a/e2e/scenes/dashboards/PanelSandboxDashboard.json b/e2e/scenes/dashboards/PanelSandboxDashboard.json new file mode 100644 index 00000000000..0ea1ca81d24 --- /dev/null +++ b/e2e/scenes/dashboards/PanelSandboxDashboard.json @@ -0,0 +1,58 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 118, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "title": "Sandbox Panel test", + "type": "sandbox-test-panel" + } + ], + "refresh": "", + "schemaVersion": 38, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Sandbox Panel Test", + "uid": "c46b2460-16b7-42a5-82d1-b07fbf431950", + "version": 1, + "weekStart": "" +} diff --git a/e2e/scenes/dashboards/TestDashboard.json b/e2e/scenes/dashboards/TestDashboard.json new file mode 100644 index 00000000000..6d4ad3e07ff --- /dev/null +++ b/e2e/scenes/dashboards/TestDashboard.json @@ -0,0 +1,209 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 321, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 6, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.3.0-pre", + "title": "Gauge Example", + "type": "gauge" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "8.3.0-pre", + "title": "Stat", + "type": "stat" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "title": "Time series example", + "type": "timeseries" + } + ], + "refresh": false, + "schemaVersion": 31, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "2021-09-01T04:00:00.000Z", + "to": "2021-09-15T04:00:00.000Z" + }, + "timepicker": {}, + "timezone": "", + "title": "E2E Test - Import Dashboard", + "uid": "kquZN5H7k", + "version": 4 +} diff --git a/e2e/scenes/shared/smokeTestScenario.ts b/e2e/scenes/shared/smokeTestScenario.ts new file mode 100644 index 00000000000..3e66419680f --- /dev/null +++ b/e2e/scenes/shared/smokeTestScenario.ts @@ -0,0 +1,40 @@ +import { e2e } from '../utils'; + +export const smokeTestScenario = () => + describe('Smoke tests', () => { + before(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), false); + cy.logToConsole('enabling dashboardScene feature toggle in localstorage'); + cy.setLocalStorage('grafana.featureToggles', 'dashboardScene=true'); + cy.reload(); + e2e.flows.addDataSource(); + e2e.flows.addDashboard(); + e2e.flows.addPanel({ + dataSourceName: 'gdev-testdata', + visitDashboardAtStart: false, + timeout: 10000, + }); + }); + + after(() => { + e2e.flows.revertAllChanges(); + }); + + it('Login scenario, create test data source, dashboard, panel, and export scenario', () => { + e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer() + .should('be.visible') + .within(() => { + cy.get('input[id*="test-data-scenario-select-"]').should('be.visible').click(); + }); + + cy.contains('CSV Metric Values').scrollIntoView().should('be.visible').click(); + + // Make sure the graph renders via checking legend + e2e.components.VizLegend.seriesName('A-series').should('be.visible'); + + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + + // Make sure panel is & visualization is added to dashboard + e2e.components.VizLegend.seriesName('A-series').should('be.visible'); + }); + }); diff --git a/e2e/scenes/smoke-tests-suite/1-smoketests.spec.ts b/e2e/scenes/smoke-tests-suite/1-smoketests.spec.ts new file mode 100644 index 00000000000..b66a6eee28f --- /dev/null +++ b/e2e/scenes/smoke-tests-suite/1-smoketests.spec.ts @@ -0,0 +1,3 @@ +import { smokeTestScenario } from '../shared/smokeTestScenario'; + +smokeTestScenario(); diff --git a/e2e/scenes/smoke-tests-suite/panels_smokescreen.spec.ts b/e2e/scenes/smoke-tests-suite/panels_smokescreen.spec.ts new file mode 100644 index 00000000000..ca63dadba19 --- /dev/null +++ b/e2e/scenes/smoke-tests-suite/panels_smokescreen.spec.ts @@ -0,0 +1,38 @@ +import { GrafanaBootConfig } from '@grafana/runtime'; + +import { e2e } from '../utils'; + +describe('Panels smokescreen', () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'), false); + }); + + after(() => { + e2e.flows.revertAllChanges(); + }); + + it('Tests each panel type in the panel edit view to ensure no crash', () => { + e2e.flows.addDashboard(); + + e2e.flows.addPanel({ + dataSourceName: 'gdev-testdata', + timeout: 10000, + visitDashboardAtStart: false, + }); + + cy.window().then((win: Cypress.AUTWindow & { grafanaBootData: GrafanaBootConfig['bootData'] }) => { + // Loop through every panel type and ensure no crash + Object.entries(win.grafanaBootData.settings.panels).forEach(([_, panel]) => { + // TODO: Remove Flame Graph check as part of addressing #66803 + if (!panel.hideFromList && panel.state !== 'deprecated') { + e2e.components.PanelEditor.toggleVizPicker().click(); + e2e.components.PluginVisualization.item(panel.name).scrollIntoView().should('be.visible').click(); + + e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain(panel.name)); + // TODO: Come up with better check / better failure messaging to clearly indicate which panel failed + cy.contains('An unexpected error happened').should('not.exist'); + } + }); + }); + }); +}); diff --git a/e2e/scenes/utils/flows/addDashboard.ts b/e2e/scenes/utils/flows/addDashboard.ts new file mode 100644 index 00000000000..77dd07e9c9b --- /dev/null +++ b/e2e/scenes/utils/flows/addDashboard.ts @@ -0,0 +1,302 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { e2e } from '../index'; +import { getDashboardUid } from '../support/url'; + +import { selectOption } from './selectOption'; +import { setDashboardTimeRange, TimeRangeConfig } from './setDashboardTimeRange'; + +export interface AddAnnotationConfig { + dataSource: string; + dataSourceForm?: () => void; + name: string; +} + +export interface AddDashboardConfig { + annotations: AddAnnotationConfig[]; + timeRange: TimeRangeConfig; + title: string; + variables: PartialAddVariableConfig[]; +} + +interface AddVariableDefault { + hide: string; + type: string; +} + +interface AddVariableOptional { + constantValue?: string; + dataSource?: string; + label?: string; + query?: string; + regex?: string; + variableQueryForm?: (config: AddVariableConfig) => void; +} + +interface AddVariableRequired { + name: string; +} + +export type PartialAddVariableConfig = Partial & AddVariableOptional & AddVariableRequired; +export type AddVariableConfig = AddVariableDefault & AddVariableOptional & AddVariableRequired; + +/** + * This flow is used to add a dashboard with whatever configuration specified. + * @param config Configuration object. Currently supports configuring dashboard time range, annotations, and variables (support dependant on type). + * @see{@link AddDashboardConfig} + * + * @example + * ``` + * // Configuring a simple dashboard + * addDashboard({ + * timeRange: { + * from: '2022-10-03 00:00:00', + * to: '2022-10-03 23:59:59', + * zone: 'Coordinated Universal Time', + * }, + * title: 'Test Dashboard', + * }) + * ``` + * + * @example + * ``` + * // Configuring a dashboard with annotations + * addDashboard({ + * title: 'Test Dashboard', + * annotations: [ + * { + * // This should match the datasource name + * dataSource: 'azure-monitor', + * name: 'Test Annotation', + * dataSourceForm: () => { + * // Insert steps to create annotation using datasource form + * } + * } + * ] + * }) + * ``` + * + * @see{@link AddAnnotationConfig} + * + * @example + * ``` + * // Configuring a dashboard with variables + * addDashboard({ + * title: 'Test Dashboard', + * variables: [ + * { + * name: 'test-query-variable', + * label: 'Testing Query', + * hide: '', + * type: e2e.flows.VARIABLE_TYPE_QUERY, + * dataSource: 'azure-monitor', + * variableQueryForm: () => { + * // Insert steps to create variable using datasource form + * }, + * }, + * { + * name: 'test-constant-variable', + * label: 'Testing Constant', + * type: e2e.flows.VARIABLE_TYPE_CONSTANT, + * constantValue: 'constant', + * } + * ] + * }) + * ``` + * + * @see{@link AddVariableConfig} + * + * @see{@link https://github.com/grafana/grafana/blob/main/e2e/cloud-plugins-suite/azure-monitor.spec.ts Azure Monitor Tests for full examples} + */ +export const addDashboard = (config?: Partial) => { + const fullConfig: AddDashboardConfig = { + annotations: [], + title: `e2e-${uuidv4()}`, + variables: [], + ...config, + timeRange: { + from: '2020-01-01 00:00:00', + to: '2020-01-01 06:00:00', + zone: 'Coordinated Universal Time', + ...config?.timeRange, + }, + }; + + const { annotations, timeRange, title, variables } = fullConfig; + + cy.logToConsole('Adding dashboard with title:', title); + + e2e.pages.AddDashboard.visit(); + + if (annotations.length > 0 || variables.length > 0) { + e2e.components.PageToolbar.item('Dashboard settings').click(); + addAnnotations(annotations); + + fullConfig.variables = addVariables(variables); + + e2e.components.BackButton.backArrow().should('be.visible').click({ force: true }); + } + + setDashboardTimeRange(timeRange); + + e2e.components.NavToolbar.editDashboard.saveButton().click(); + e2e.components.Drawer.DashboardSaveDrawer.saveAsTitleInput().clear().type(title, { force: true }); + e2e.components.Drawer.DashboardSaveDrawer.saveButton().click(); + e2e.flows.assertSuccessNotification(); + e2e.pages.AddDashboard.itemButton('Create new panel button').should('be.visible'); + + cy.logToConsole('Added dashboard with title:', title); + + return cy + .url() + .should('contain', '/d/') + .then((url: string) => { + const uid = getDashboardUid(url); + + e2e.getScenarioContext().then(({ addedDashboards }) => { + e2e.setScenarioContext({ + addedDashboards: [...addedDashboards, { title, uid }], + }); + }); + + // @todo remove `wrap` when possible + return cy.wrap( + { + config: fullConfig, + uid, + }, + { log: false } + ); + }); +}; + +const addAnnotation = (config: AddAnnotationConfig, isFirst: boolean) => { + if (isFirst) { + if (e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2) { + e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2().click(); + } else { + e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTA().click(); + } + } else { + cy.contains('New query').click(); + } + + const { dataSource, dataSourceForm, name } = config; + + selectOption({ + container: e2e.components.DataSourcePicker.container(), + optionText: dataSource, + }); + + e2e.pages.Dashboard.Settings.Annotations.Settings.name().clear().type(name); + + if (dataSourceForm) { + dataSourceForm(); + } +}; + +const addAnnotations = (configs: AddAnnotationConfig[]) => { + if (configs.length > 0) { + e2e.pages.Dashboard.Settings.General.sectionItems('Annotations').click(); + } + + return configs.forEach((config, i) => addAnnotation(config, i === 0)); +}; + +export const VARIABLE_HIDE_LABEL = 'Label'; +export const VARIABLE_HIDE_NOTHING = ''; +export const VARIABLE_HIDE_VARIABLE = 'Variable'; + +export const VARIABLE_TYPE_AD_HOC_FILTERS = 'Ad hoc filters'; +export const VARIABLE_TYPE_CONSTANT = 'Constant'; +export const VARIABLE_TYPE_DATASOURCE = 'Datasource'; +export const VARIABLE_TYPE_QUERY = 'Query'; + +const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVariableConfig => { + const fullConfig = { + hide: VARIABLE_HIDE_NOTHING, + type: VARIABLE_TYPE_QUERY, + ...config, + }; + + if (isFirst) { + if (e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2) { + e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2().click(); + } else { + e2e.pages.Dashboard.Settings.Variables.List.addVariableCTA().click(); + } + } else { + e2e.pages.Dashboard.Settings.Variables.List.newButton().click(); + } + + const { constantValue, dataSource, label, name, query, regex, type, variableQueryForm } = fullConfig; + + // This field is key to many reactive changes + if (type !== VARIABLE_TYPE_QUERY) { + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2() + .should('be.visible') + .within(() => { + e2e.components.Select.singleValue().should('have.text', 'Query').parent().click(); + }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().find('input').type(`${type}{enter}`); + } + + if (label) { + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type(label); + } + + e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(name); + + if ( + dataSource && + (type === VARIABLE_TYPE_AD_HOC_FILTERS || type === VARIABLE_TYPE_DATASOURCE || type === VARIABLE_TYPE_QUERY) + ) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect() + .should('be.visible') + .within(() => { + e2e.components.DataSourcePicker.inputV2().type(`${dataSource}{enter}`); + }); + } + + if (constantValue && type === VARIABLE_TYPE_CONSTANT) { + e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInputV2().type(constantValue); + } + + if (type === VARIABLE_TYPE_QUERY) { + if (query) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput().type(query); + } + + if (regex) { + e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2().type(regex); + } + + if (variableQueryForm) { + variableQueryForm(fullConfig); + } + } + + // Avoid flakiness + cy.focused().blur(); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption() + .should('exist') + .within((previewOfValues) => { + if (type === VARIABLE_TYPE_CONSTANT) { + expect(previewOfValues.text()).equals(constantValue); + } + }); + + e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); + e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click(); + + return fullConfig; +}; + +const addVariables = (configs: PartialAddVariableConfig[]): AddVariableConfig[] => { + if (configs.length > 0) { + e2e.components.Tab.title('Variables').click(); + } + + return configs.map((config, i) => addVariable(config, i === 0)); +}; diff --git a/e2e/scenes/utils/flows/addDataSource.ts b/e2e/scenes/utils/flows/addDataSource.ts new file mode 100644 index 00000000000..777ed6caf31 --- /dev/null +++ b/e2e/scenes/utils/flows/addDataSource.ts @@ -0,0 +1,109 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { e2e } from '../index'; + +export interface AddDataSourceConfig { + basicAuth: boolean; + basicAuthPassword: string; + basicAuthUser: string; + expectedAlertMessage: string | RegExp; + form: () => void; + name: string; + skipTlsVerify: boolean; + type: string; + timeout?: number; + awaitHealth?: boolean; +} + +// @todo this actually returns type `Cypress.Chainable` +export const addDataSource = (config?: Partial) => { + const fullConfig: AddDataSourceConfig = { + basicAuth: false, + basicAuthPassword: '', + basicAuthUser: '', + expectedAlertMessage: 'Data source is working', + form: () => {}, + name: `e2e-${uuidv4()}`, + skipTlsVerify: false, + type: 'TestData', + ...config, + }; + + const { + basicAuth, + basicAuthPassword, + basicAuthUser, + expectedAlertMessage, + form, + name, + skipTlsVerify, + type, + timeout, + awaitHealth, + } = fullConfig; + + if (awaitHealth) { + cy.intercept(/health/).as('health'); + } + + cy.logToConsole('Adding data source with name:', name); + e2e.pages.AddDataSource.visit(); + e2e.pages.AddDataSource.dataSourcePluginsV2(type) + .scrollIntoView() + .should('be.visible') // prevents flakiness + .click(); + + e2e.pages.DataSource.name().clear(); + e2e.pages.DataSource.name().type(name); + + if (basicAuth) { + cy.contains('label', 'Basic auth').scrollIntoView().click(); + cy.contains('.gf-form-group', 'Basic Auth Details') + .should('be.visible') + .scrollIntoView() + .within(() => { + if (basicAuthUser) { + cy.get('[placeholder=user]').type(basicAuthUser); + } + if (basicAuthPassword) { + cy.get('[placeholder=Password]').type(basicAuthPassword); + } + }); + } + + if (skipTlsVerify) { + cy.contains('label', 'Skip TLS Verify').scrollIntoView().click(); + } + + form(); + + e2e.pages.DataSource.saveAndTest().click(); + + if (awaitHealth) { + cy.wait('@health', { timeout: timeout ?? Cypress.config().defaultCommandTimeout }); + } + + // use the timeout passed in if it exists, otherwise, continue to use the default + e2e.pages.DataSource.alert() + .should('exist') + .contains(expectedAlertMessage, { + timeout: timeout ?? Cypress.config().defaultCommandTimeout, + }); + cy.logToConsole('Added data source with name:', name); + + return cy.url().then(() => { + e2e.getScenarioContext().then(({ addedDataSources }) => { + e2e.setScenarioContext({ + addedDataSources: [...addedDataSources, { name, id: '' }], + }); + }); + + // @todo remove `wrap` when possible + return cy.wrap( + { + config: fullConfig, + }, + { log: false } + ); + }); +}; diff --git a/e2e/scenes/utils/flows/addPanel.ts b/e2e/scenes/utils/flows/addPanel.ts new file mode 100644 index 00000000000..e906415ff54 --- /dev/null +++ b/e2e/scenes/utils/flows/addPanel.ts @@ -0,0 +1,15 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { getScenarioContext } from '../support/scenarioContext'; + +import { configurePanel, PartialAddPanelConfig } from './configurePanel'; + +export const addPanel = (config?: Partial) => + getScenarioContext().then(({ lastAddedDataSource }) => + configurePanel({ + dataSourceName: lastAddedDataSource, + panelTitle: `e2e-${uuidv4()}`, + ...config, + isEdit: false, + }) + ); diff --git a/e2e/scenes/utils/flows/assertSuccessNotification.ts b/e2e/scenes/utils/flows/assertSuccessNotification.ts new file mode 100644 index 00000000000..25f9228683c --- /dev/null +++ b/e2e/scenes/utils/flows/assertSuccessNotification.ts @@ -0,0 +1,9 @@ +import { e2e } from '../index'; + +export const assertSuccessNotification = () => { + if (e2e.components.Alert.alertV2) { + e2e.components.Alert.alertV2('success').should('exist'); + } else { + e2e.components.Alert.alert('success').should('exist'); + } +}; diff --git a/e2e/scenes/utils/flows/configurePanel.ts b/e2e/scenes/utils/flows/configurePanel.ts new file mode 100644 index 00000000000..7a881a47c17 --- /dev/null +++ b/e2e/scenes/utils/flows/configurePanel.ts @@ -0,0 +1,177 @@ +import { e2e } from '..'; +import { getScenarioContext } from '../support/scenarioContext'; + +import { setDashboardTimeRange } from './setDashboardTimeRange'; +import { TimeRangeConfig } from './setTimeRange'; + +interface AddPanelOverrides { + dataSourceName: string; + queriesForm: (config: AddPanelConfig) => void; + panelTitle: string; +} + +interface EditPanelOverrides { + queriesForm?: (config: EditPanelConfig) => void; + panelTitle: string; +} + +interface ConfigurePanelDefault { + chartData: { + method: string; + route: string | RegExp; + }; + dashboardUid: string; + saveDashboard: boolean; + visitDashboardAtStart: boolean; // @todo remove when possible +} + +interface ConfigurePanelOptional { + dataSourceName?: string; + queriesForm?: (config: ConfigurePanelConfig) => void; + panelTitle?: string; + timeRange?: TimeRangeConfig; + visualizationName?: string; + timeout?: number; +} + +interface ConfigurePanelRequired { + isEdit: boolean; +} + +export type PartialConfigurePanelConfig = Partial & + ConfigurePanelOptional & + ConfigurePanelRequired; + +export type ConfigurePanelConfig = ConfigurePanelDefault & ConfigurePanelOptional & ConfigurePanelRequired; + +export type PartialAddPanelConfig = PartialConfigurePanelConfig & AddPanelOverrides; +export type AddPanelConfig = ConfigurePanelConfig & AddPanelOverrides; + +export type PartialEditPanelConfig = PartialConfigurePanelConfig & EditPanelOverrides; +export type EditPanelConfig = ConfigurePanelConfig & EditPanelOverrides; + +export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelConfig | PartialConfigurePanelConfig) => + getScenarioContext().then(({ lastAddedDashboardUid }) => { + const fullConfig: AddPanelConfig | EditPanelConfig | ConfigurePanelConfig = { + chartData: { + method: 'POST', + route: '/api/ds/query', + }, + dashboardUid: lastAddedDashboardUid, + saveDashboard: true, + visitDashboardAtStart: true, + ...config, + }; + + const { + chartData, + dashboardUid, + dataSourceName, + isEdit, + panelTitle, + queriesForm, + timeRange, + visitDashboardAtStart, + visualizationName, + timeout, + } = fullConfig; + + if (visitDashboardAtStart) { + e2e.flows.openDashboard({ uid: dashboardUid }); + } + + if (isEdit) { + e2e.components.Panels.Panel.title(panelTitle).click(); + e2e.components.Panels.Panel.headerItems('Edit').click(); + } else { + try { + //Enter edit mode + e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click(); + e2e.components.PageToolbar.itemButton('Add button').should('be.visible').click(); + e2e.components.NavToolbar.editDashboard.addVisualizationButton().should('be.visible').click(); + } catch (e) { + // Depending on the screen size, the "Add" button might be hidden + e2e.components.PageToolbar.item('Show more items').click(); + e2e.components.PageToolbar.item('Add button').last().click(); + } + // e2e.pages.AddDashboard.itemButton('Add new visualization menu item').should('be.visible'); + // e2e.pages.AddDashboard.itemButton('Add new visualization menu item').click(); + } + + if (timeRange) { + setDashboardTimeRange(timeRange); + } + + // @todo alias '/**/*.js*' as '@pluginModule' when possible: https://github.com/cypress-io/cypress/issues/1296 + + cy.intercept(chartData.method, chartData.route).as('chartData'); + + if (dataSourceName) { + e2e.components.DataSourcePicker.container().click().type(`${dataSourceName}{downArrow}{enter}`); + } + + // @todo instead wait for '@pluginModule' if not already loaded + cy.wait(2000); + + // `panelTitle` is needed to edit the panel, and unlikely to have its value changed at that point + const changeTitle = panelTitle && !isEdit; + + if (changeTitle || visualizationName) { + if (changeTitle && panelTitle) { + e2e.components.PanelEditor.OptionsPane.fieldLabel('Panel options Title').type(`{selectall}${panelTitle}`); + } + + if (visualizationName) { + e2e.components.PluginVisualization.item(visualizationName).scrollIntoView().click(); + + // @todo wait for '@pluginModule' if not a core visualization and not already loaded + cy.wait(2000); + } + } else { + // Consistently closed + closeOptions(); + } + + if (queriesForm) { + queriesForm(fullConfig); + + // Wait for a possible complex visualization to render (or something related, as this isn't necessary on the dashboard page) + // Can't assert that its HTML changed because a new query could produce the same results + cy.wait(1000); + } + + // @todo enable when plugins have this implemented + //e2e.components.QueryEditorRow.actionButton('Disable/enable query').click(); + //cy.wait('@chartData'); + //e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content').contains('No data'); + //e2e.components.QueryEditorRow.actionButton('Disable/enable query').click(); + //cy.wait('@chartData'); + + // Avoid annotations flakiness + e2e.components.RefreshPicker.runButtonV2().first().click({ force: true }); + + // Wait for RxJS + cy.wait(timeout ?? Cypress.config().defaultCommandTimeout); + + // @todo remove `wrap` when possible + return cy.wrap({ config: fullConfig }, { log: false }); + }); + +const closeOptions = () => e2e.components.PanelEditor.toggleVizOptions().click(); + +export const VISUALIZATION_ALERT_LIST = 'Alert list'; +export const VISUALIZATION_BAR_GAUGE = 'Bar gauge'; +export const VISUALIZATION_CLOCK = 'Clock'; +export const VISUALIZATION_DASHBOARD_LIST = 'Dashboard list'; +export const VISUALIZATION_GAUGE = 'Gauge'; +export const VISUALIZATION_GRAPH = 'Graph'; +export const VISUALIZATION_HEAT_MAP = 'Heatmap'; +export const VISUALIZATION_LOGS = 'Logs'; +export const VISUALIZATION_NEWS = 'News'; +export const VISUALIZATION_PIE_CHART = 'Pie Chart'; +export const VISUALIZATION_PLUGIN_LIST = 'Plugin list'; +export const VISUALIZATION_POLYSTAT = 'Polystat'; +export const VISUALIZATION_STAT = 'Stat'; +export const VISUALIZATION_TABLE = 'Table'; +export const VISUALIZATION_TEXT = 'Text'; +export const VISUALIZATION_WORLD_MAP = 'Worldmap Panel'; diff --git a/e2e/scenes/utils/flows/confirmModal.ts b/e2e/scenes/utils/flows/confirmModal.ts new file mode 100644 index 00000000000..1f2c63f4577 --- /dev/null +++ b/e2e/scenes/utils/flows/confirmModal.ts @@ -0,0 +1,6 @@ +import { e2e } from '..'; + +export function confirmDelete() { + cy.get(`input[placeholder='Type "Delete" to confirm']`).type('Delete'); + e2e.pages.ConfirmModal.delete().click(); +} diff --git a/e2e/scenes/utils/flows/deleteDashboard.ts b/e2e/scenes/utils/flows/deleteDashboard.ts new file mode 100644 index 00000000000..de492012c2f --- /dev/null +++ b/e2e/scenes/utils/flows/deleteDashboard.ts @@ -0,0 +1,49 @@ +import { e2e } from '../index'; +import { fromBaseUrl } from '../support/url'; + +export interface DeleteDashboardConfig { + quick?: boolean; + title: string; + uid: string; +} + +export const deleteDashboard = ({ quick = false, title, uid }: DeleteDashboardConfig) => { + cy.logToConsole('Deleting dashboard with uid:', uid); + + if (quick) { + quickDelete(uid); + } else { + uiDelete(uid, title); + } + + cy.logToConsole('Deleted dashboard with uid:', uid); + + e2e.getScenarioContext().then(({ addedDashboards }) => { + e2e.setScenarioContext({ + addedDashboards: addedDashboards.filter((dashboard: DeleteDashboardConfig) => { + return dashboard.title !== title && dashboard.uid !== uid; + }), + }); + }); +}; + +const quickDelete = (uid: string) => { + cy.request('DELETE', fromBaseUrl(`/api/dashboards/uid/${uid}`)); +}; + +const uiDelete = (uid: string, title: string) => { + e2e.pages.Dashboard.visit(uid); + e2e.components.PageToolbar.item('Dashboard settings').click(); + e2e.pages.Dashboard.Settings.General.deleteDashBoard().click(); + e2e.pages.ConfirmModal.delete().click(); + e2e.flows.assertSuccessNotification(); + + e2e.pages.Dashboards.visit(); + + // @todo replace `e2e.pages.Dashboards.dashboards` with this when argument is empty + if (e2e.components.Search.dashboardItems) { + e2e.components.Search.dashboardItems().each((item) => cy.wrap(item).should('not.contain', title)); + } else { + cy.get('[aria-label^="Dashboard search item "]').each((item) => cy.wrap(item).should('not.contain', title)); + } +}; diff --git a/e2e/scenes/utils/flows/deleteDataSource.ts b/e2e/scenes/utils/flows/deleteDataSource.ts new file mode 100644 index 00000000000..359c899d01d --- /dev/null +++ b/e2e/scenes/utils/flows/deleteDataSource.ts @@ -0,0 +1,44 @@ +import { e2e } from '../index'; +import { fromBaseUrl } from '../support/url'; + +export interface DeleteDataSourceConfig { + id: string; + name: string; + quick?: boolean; +} + +export const deleteDataSource = ({ id, name, quick = false }: DeleteDataSourceConfig) => { + cy.logToConsole('Deleting data source with name:', name); + + if (quick) { + quickDelete(name); + } else { + uiDelete(name); + } + + cy.logToConsole('Deleted data source with name:', name); + + e2e.getScenarioContext().then(({ addedDataSources }) => { + e2e.setScenarioContext({ + addedDataSources: addedDataSources.filter((dataSource: DeleteDataSourceConfig) => { + return dataSource.id !== id && dataSource.name !== name; + }), + }); + }); +}; + +const quickDelete = (name: string) => { + cy.request('DELETE', fromBaseUrl(`/api/datasources/name/${name}`)); +}; + +const uiDelete = (name: string) => { + e2e.pages.DataSources.visit(); + e2e.pages.DataSources.dataSources(name).click(); + e2e.pages.DataSource.delete().click(); + e2e.pages.ConfirmModal.delete().click(); + + e2e.pages.DataSources.visit(); + + // @todo replace `e2e.pages.DataSources.dataSources` with this when argument is empty + cy.get('[aria-label^="Data source list item "]').each((item) => cy.wrap(item).should('not.contain', name)); +}; diff --git a/e2e/scenes/utils/flows/editPanel.ts b/e2e/scenes/utils/flows/editPanel.ts new file mode 100644 index 00000000000..c213465902a --- /dev/null +++ b/e2e/scenes/utils/flows/editPanel.ts @@ -0,0 +1,7 @@ +import { configurePanel, PartialEditPanelConfig } from './configurePanel'; + +export const editPanel = (config: Partial) => + configurePanel({ + ...config, + isEdit: true, + }); diff --git a/e2e/scenes/utils/flows/importDashboard.ts b/e2e/scenes/utils/flows/importDashboard.ts new file mode 100644 index 00000000000..4e7b8ade4ad --- /dev/null +++ b/e2e/scenes/utils/flows/importDashboard.ts @@ -0,0 +1,69 @@ +import { e2e } from '../index'; +import { fromBaseUrl, getDashboardUid } from '../support/url'; + +import { DeleteDashboardConfig } from '.'; + +type Panel = { + title: string; + [key: string]: unknown; +}; + +export type Dashboard = { title: string; panels: Panel[]; uid: string; [key: string]: unknown }; + +/** + * Smoke test a particular dashboard by quickly importing a json file and validate that all the panels finish loading + * @param dashboardToImport a sample dashboard + * @param queryTimeout a number of ms to wait for the imported dashboard to finish loading + * @param skipPanelValidation skip panel validation + */ +export const importDashboard = (dashboardToImport: Dashboard, queryTimeout?: number, skipPanelValidation?: boolean) => { + cy.visit(fromBaseUrl('/dashboard/import')); + + // Note: normally we'd use 'click' and then 'type' here, but the json object is so big that using 'val' is much faster + e2e.components.DashboardImportPage.textarea().should('be.visible'); + e2e.components.DashboardImportPage.textarea().click(); + e2e.components.DashboardImportPage.textarea().invoke('val', JSON.stringify(dashboardToImport)); + e2e.components.DashboardImportPage.submit().should('be.visible').click(); + e2e.components.ImportDashboardForm.name().should('be.visible').click().clear().type(dashboardToImport.title); + e2e.components.ImportDashboardForm.submit().should('be.visible').click(); + + // wait for dashboard to load + cy.wait(queryTimeout || 6000); + + // save the newly imported dashboard to context so it'll get properly deleted later + cy.url() + .should('contain', '/d/') + .then((url: string) => { + const uid = getDashboardUid(url); + + e2e.getScenarioContext().then(({ addedDashboards }: { addedDashboards: DeleteDashboardConfig[] }) => { + e2e.setScenarioContext({ + addedDashboards: [...addedDashboards, { title: dashboardToImport.title, uid }], + }); + }); + + expect(dashboardToImport.uid).to.equal(uid); + }); + + if (!skipPanelValidation) { + dashboardToImport.panels.forEach((panel) => { + // Look at the json data + e2e.components.Panels.Panel.menu(panel.title).click({ force: true }); // force click because menu is hidden and show on hover + e2e.components.Panels.Panel.menuItems('Inspect').should('be.visible').click(); + e2e.components.Tab.title('JSON').should('be.visible').click(); + e2e.components.PanelInspector.Json.content().should('be.visible').contains('Panel JSON').click({ force: true }); + e2e.components.Select.option().should('be.visible').contains('Panel data').click(); + + // ensures that panel has loaded without knowingly hitting an error + // note: this does not prove that data came back as we expected it, + // it could get `state: Done` for no data for example + // but it ensures we didn't hit a 401 or 500 or something like that + e2e.components.CodeEditor.container() + .should('be.visible') + .contains(/"state": "(Done|Streaming)"/); + + // need to close panel + e2e.components.Drawer.General.close().click(); + }); + } +}; diff --git a/e2e/scenes/utils/flows/importDashboards.ts b/e2e/scenes/utils/flows/importDashboards.ts new file mode 100644 index 00000000000..7434aeb11e9 --- /dev/null +++ b/e2e/scenes/utils/flows/importDashboards.ts @@ -0,0 +1,17 @@ +import { importDashboard, Dashboard } from './importDashboard'; + +/** + * Smoke test several dashboard json files from a test directory + * and validate that all the panels in each import finish loading their queries + * @param dirPath the relative path to a directory which contains json files representing dashboards, + * for example if your dashboards live in `cypress/testDashboards` you can pass `/testDashboards` + * @param queryTimeout a number of ms to wait for the imported dashboard to finish loading + * @param skipPanelValidation skips panel validation + */ +export const importDashboards = async (dirPath: string, queryTimeout?: number, skipPanelValidation?: boolean) => { + cy.getJSONFilesFromDir(dirPath).then((jsonFiles: Dashboard[]) => { + jsonFiles.forEach((file) => { + importDashboard(file, queryTimeout || 6000, skipPanelValidation); + }); + }); +}; diff --git a/e2e/scenes/utils/flows/index.ts b/e2e/scenes/utils/flows/index.ts new file mode 100644 index 00000000000..ae07146d13e --- /dev/null +++ b/e2e/scenes/utils/flows/index.ts @@ -0,0 +1,37 @@ +export * from './addDashboard'; +export * from './addDataSource'; +export * from './addPanel'; +export * from './assertSuccessNotification'; +export * from './deleteDashboard'; +export * from './deleteDataSource'; +export * from './editPanel'; +export * from './login'; +export * from './openDashboard'; +export * from './openPanelMenuItem'; +export * from './revertAllChanges'; +export * from './saveDashboard'; +export * from './selectOption'; +export * from './setTimeRange'; +export * from './importDashboard'; +export * from './importDashboards'; +export * from './userPreferences'; +export * from './confirmModal'; + +export { + VISUALIZATION_ALERT_LIST, + VISUALIZATION_BAR_GAUGE, + VISUALIZATION_CLOCK, + VISUALIZATION_DASHBOARD_LIST, + VISUALIZATION_GAUGE, + VISUALIZATION_GRAPH, + VISUALIZATION_HEAT_MAP, + VISUALIZATION_LOGS, + VISUALIZATION_NEWS, + VISUALIZATION_PIE_CHART, + VISUALIZATION_PLUGIN_LIST, + VISUALIZATION_POLYSTAT, + VISUALIZATION_STAT, + VISUALIZATION_TABLE, + VISUALIZATION_TEXT, + VISUALIZATION_WORLD_MAP, +} from './configurePanel'; diff --git a/e2e/scenes/utils/flows/login.ts b/e2e/scenes/utils/flows/login.ts new file mode 100644 index 00000000000..fc06f7c1fe1 --- /dev/null +++ b/e2e/scenes/utils/flows/login.ts @@ -0,0 +1,42 @@ +import { e2e } from '../index'; +import { fromBaseUrl } from '../support/url'; + +const DEFAULT_USERNAME = 'admin'; +const DEFAULT_PASSWORD = 'admin'; + +const loginApi = (username: string, password: string) => { + cy.request({ + method: 'POST', + url: fromBaseUrl('/login'), + body: { + user: username, + password, + }, + }); +}; + +const loginUi = (username: string, password: string) => { + cy.logToConsole('Logging in with username:', username); + e2e.pages.Login.visit(); + e2e.pages.Login.username() + .should('be.visible') // prevents flakiness + .type(username); + e2e.pages.Login.password().type(password); + e2e.pages.Login.submit().click(); + + // Local tests will have insecure credentials + if (password === DEFAULT_PASSWORD) { + e2e.pages.Login.skip().should('be.visible').click(); + } + + cy.get('.login-page').should('not.exist'); +}; + +export const login = (username = DEFAULT_USERNAME, password = DEFAULT_PASSWORD, loginViaApi = true) => { + if (loginViaApi) { + loginApi(username, password); + } else { + loginUi(username, password); + } + cy.logToConsole('Logged in with username:', username); +}; diff --git a/e2e/scenes/utils/flows/openDashboard.ts b/e2e/scenes/utils/flows/openDashboard.ts new file mode 100644 index 00000000000..3c666a11fd1 --- /dev/null +++ b/e2e/scenes/utils/flows/openDashboard.ts @@ -0,0 +1,35 @@ +import { e2e } from '../index'; +import { getScenarioContext } from '../support/scenarioContext'; + +import { setDashboardTimeRange, TimeRangeConfig } from './setDashboardTimeRange'; + +interface OpenDashboardDefault { + uid: string; +} + +interface OpenDashboardOptional { + timeRange?: TimeRangeConfig; + queryParams?: object; +} + +export type PartialOpenDashboardConfig = Partial & OpenDashboardOptional; +export type OpenDashboardConfig = OpenDashboardDefault & OpenDashboardOptional; + +export const openDashboard = (config?: PartialOpenDashboardConfig) => + getScenarioContext().then(({ lastAddedDashboardUid }) => { + const fullConfig: OpenDashboardConfig = { + uid: lastAddedDashboardUid, + ...config, + }; + + const { timeRange, uid, queryParams } = fullConfig; + + e2e.pages.Dashboard.visit(uid, queryParams); + + if (timeRange) { + setDashboardTimeRange(timeRange); + } + + // @todo remove `wrap` when possible + return cy.wrap({ config: fullConfig }, { log: false }); + }); diff --git a/e2e/scenes/utils/flows/openPanelMenuItem.ts b/e2e/scenes/utils/flows/openPanelMenuItem.ts new file mode 100644 index 00000000000..1ccd5206826 --- /dev/null +++ b/e2e/scenes/utils/flows/openPanelMenuItem.ts @@ -0,0 +1,57 @@ +import { e2e } from '../index'; + +export enum PanelMenuItems { + Edit = 'Edit', + Inspect = 'Inspect', + More = 'More...', + Extensions = 'Extensions', +} + +export const openPanelMenuItem = (menu: PanelMenuItems, panelTitle = 'Panel Title') => { + // we changed the way we open the panel menu in react panels with the new panel header + detectPanelType(panelTitle, (isAngularPanel) => { + if (isAngularPanel) { + e2e.components.Panels.Panel.title(panelTitle).should('be.visible').click(); + e2e.components.Panels.Panel.headerItems(menu).should('be.visible').click(); + } else { + e2e.components.Panels.Panel.menu(panelTitle).click({ force: true }); // force click because menu is hidden and show on hover + e2e.components.Panels.Panel.menuItems(menu).should('be.visible').click(); + } + }); +}; + +export const openPanelMenuExtension = (extensionTitle: string, panelTitle = 'Panel Title') => { + const menuItem = PanelMenuItems.Extensions; + // we changed the way we open the panel menu in react panels with the new panel header + detectPanelType(panelTitle, (isAngularPanel) => { + if (isAngularPanel) { + e2e.components.Panels.Panel.title(panelTitle).should('be.visible').click(); + e2e.components.Panels.Panel.headerItems(menuItem) + .should('be.visible') + .parent() + .parent() + .invoke('addClass', 'open'); + e2e.components.Panels.Panel.headerItems(extensionTitle).should('be.visible').click(); + } else { + e2e.components.Panels.Panel.menu(panelTitle).click({ force: true }); // force click because menu is hidden and show on hover + e2e.components.Panels.Panel.menuItems(menuItem).trigger('mouseover', { force: true }); + e2e.components.Panels.Panel.menuItems(extensionTitle).click({ force: true }); + } + }); +}; + +function detectPanelType(panelTitle: string, detected: (isAngularPanel: boolean) => void) { + e2e.components.Panels.Panel.title(panelTitle).then((el) => { + const isAngularPanel = el.find('plugin-component.ng-scope').length > 0; + + if (isAngularPanel) { + Cypress.log({ + name: 'detectPanelType', + displayName: 'detector', + message: 'Angular panel detected, will use legacy selectors.', + }); + } + + detected(isAngularPanel); + }); +} diff --git a/e2e/scenes/utils/flows/revertAllChanges.ts b/e2e/scenes/utils/flows/revertAllChanges.ts new file mode 100644 index 00000000000..7551dd1b74a --- /dev/null +++ b/e2e/scenes/utils/flows/revertAllChanges.ts @@ -0,0 +1,12 @@ +import { e2e } from '../index'; + +export const revertAllChanges = () => { + e2e.getScenarioContext().then(({ addedDashboards, addedDataSources, hasChangedUserPreferences }) => { + addedDashboards.forEach((dashboard) => e2e.flows.deleteDashboard({ ...dashboard, quick: true })); + addedDataSources.forEach((dataSource) => e2e.flows.deleteDataSource({ ...dataSource, quick: true })); + + if (hasChangedUserPreferences) { + e2e.flows.setDefaultUserPreferences(); + } + }); +}; diff --git a/e2e/scenes/utils/flows/saveDashboard.ts b/e2e/scenes/utils/flows/saveDashboard.ts new file mode 100644 index 00000000000..37d5238f431 --- /dev/null +++ b/e2e/scenes/utils/flows/saveDashboard.ts @@ -0,0 +1,9 @@ +import { e2e } from '../index'; + +export const saveDashboard = () => { + e2e.components.PageToolbar.item('Save dashboard').click(); + + e2e.pages.SaveDashboardModal.save().click(); + + e2e.flows.assertSuccessNotification(); +}; diff --git a/e2e/scenes/utils/flows/selectOption.ts b/e2e/scenes/utils/flows/selectOption.ts new file mode 100644 index 00000000000..68f3717f1bd --- /dev/null +++ b/e2e/scenes/utils/flows/selectOption.ts @@ -0,0 +1,40 @@ +import { e2e } from '../index'; + +export interface SelectOptionConfig { + clickToOpen?: boolean; + container: Cypress.Chainable>; + forceClickOption?: boolean; + optionText: string | RegExp; +} + +export const selectOption = (config: SelectOptionConfig) => { + const fullConfig: SelectOptionConfig = { + clickToOpen: true, + forceClickOption: false, + ...config, + }; + + const { clickToOpen, container, forceClickOption, optionText } = fullConfig; + + container.within(() => { + if (clickToOpen) { + cy.get('[class$="-input-suffix"]', { timeout: 1000 }).then((element) => { + expect(Cypress.dom.isAttached(element)).to.eq(true); + cy.get('[class$="-input-suffix"]', { timeout: 1000 }).click({ force: true }); + }); + } + }); + + return e2e.components.Select.option() + .filter((_, { textContent }) => { + if (textContent === null) { + return false; + } else if (typeof optionText === 'string') { + return textContent.includes(optionText); + } else { + return optionText.test(textContent); + } + }) + .scrollIntoView() + .click({ force: forceClickOption }); +}; diff --git a/e2e/scenes/utils/flows/setDashboardTimeRange.ts b/e2e/scenes/utils/flows/setDashboardTimeRange.ts new file mode 100644 index 00000000000..566934c24e7 --- /dev/null +++ b/e2e/scenes/utils/flows/setDashboardTimeRange.ts @@ -0,0 +1,5 @@ +import { setTimeRange, TimeRangeConfig } from './setTimeRange'; + +export type { TimeRangeConfig }; + +export const setDashboardTimeRange = (config: TimeRangeConfig) => setTimeRange(config); diff --git a/e2e/scenes/utils/flows/setTimeRange.ts b/e2e/scenes/utils/flows/setTimeRange.ts new file mode 100644 index 00000000000..ac3b1898b97 --- /dev/null +++ b/e2e/scenes/utils/flows/setTimeRange.ts @@ -0,0 +1,40 @@ +import { e2e } from '../index'; + +import { selectOption } from './selectOption'; + +export interface TimeRangeConfig { + from: string; + to: string; + zone?: string; +} + +export const setTimeRange = ({ from, to, zone }: TimeRangeConfig) => { + e2e.components.TimePicker.openButton().click(); + + if (zone) { + cy.contains('button', 'Change time settings').click(); + cy.log('setting time zone to ' + zone); + + if (e2e.components.TimeZonePicker.containerV2) { + selectOption({ + clickToOpen: true, + container: e2e.components.TimeZonePicker.containerV2(), + optionText: zone, + }); + } else { + selectOption({ + clickToOpen: true, + container: e2e.components.TimeZonePicker.container(), + optionText: zone, + }); + } + } + + // For smaller screens + e2e.components.TimePicker.absoluteTimeRangeTitle().click(); + + e2e.components.TimePicker.fromField().clear().type(from); + e2e.components.TimePicker.toField().clear().type(to); + + e2e.components.TimePicker.applyTimeRange().click(); +}; diff --git a/e2e/scenes/utils/flows/userPreferences.ts b/e2e/scenes/utils/flows/userPreferences.ts new file mode 100644 index 00000000000..621bff04ce6 --- /dev/null +++ b/e2e/scenes/utils/flows/userPreferences.ts @@ -0,0 +1,25 @@ +import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; + +import { e2e } from '..'; +import { fromBaseUrl } from '../support/url'; + +const defaultUserPreferences = { + timezone: '', // "Default" option +} as const; // TODO: when we update typescript >4.9 change to `as const satisfies UserPreferencesDTO` + +// Only accept preferences we have defaults for as arguments. To allow a new preference to be set, add a default for it +type UserPreferences = Pick; + +export function setUserPreferences(prefs: UserPreferences) { + e2e.setScenarioContext({ hasChangedUserPreferences: prefs !== defaultUserPreferences }); + + return cy.request({ + method: 'PUT', + url: fromBaseUrl('/api/user/preferences'), + body: prefs, + }); +} + +export function setDefaultUserPreferences() { + return setUserPreferences(defaultUserPreferences); +} diff --git a/e2e/scenes/utils/index.ts b/e2e/scenes/utils/index.ts new file mode 100644 index 00000000000..011250f7b8a --- /dev/null +++ b/e2e/scenes/utils/index.ts @@ -0,0 +1,18 @@ +import { E2ESelectors, Selectors, selectors } from '@grafana/e2e-selectors'; + +import * as flows from './flows'; +import { e2eFactory } from './support'; +import { benchmark } from './support/benchmark'; +import { getScenarioContext, setScenarioContext } from './support/scenarioContext'; +import * as typings from './typings'; + +export const e2e = { + benchmark, + pages: e2eFactory({ selectors: selectors.pages }), + typings, + components: e2eFactory({ selectors: selectors.components }), + flows, + getScenarioContext, + setScenarioContext, + getSelectors: (selectors: E2ESelectors) => e2eFactory({ selectors }), +}; diff --git a/e2e/scenes/utils/support/benchmark.ts b/e2e/scenes/utils/support/benchmark.ts new file mode 100644 index 00000000000..1fd32e33e22 --- /dev/null +++ b/e2e/scenes/utils/support/benchmark.ts @@ -0,0 +1,70 @@ +import { e2e } from '../'; + +export interface BenchmarkArguments { + name: string; + dashboard: { + folder: string; + delayAfterOpening: number; + skipPanelValidation: boolean; + }; + repeat: number; + duration: number; + appStats?: { + startCollecting?: (window: Window) => void; + collect: (window: Window) => Record; + }; + skipScenario?: boolean; +} + +export const benchmark = ({ + name, + skipScenario = false, + repeat, + duration, + appStats, + dashboard, +}: BenchmarkArguments) => { + if (skipScenario) { + describe(name, () => { + it.skip(name, () => {}); + }); + } else { + describe(name, () => { + beforeEach(() => { + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + e2e.flows.importDashboards(dashboard.folder, 1000, dashboard.skipPanelValidation); + }); + + afterEach(() => e2e.flows.revertAllChanges()); + + Array(repeat) + .fill(0) + .map((_, i) => { + const testName = `${name}-${i}`; + return it(testName, () => { + e2e.flows.openDashboard(); + + cy.wait(dashboard.delayAfterOpening); + + if (appStats) { + const startCollecting = appStats.startCollecting; + if (startCollecting) { + cy.window().then((win) => startCollecting(win)); + } + + cy.startBenchmarking(testName); + cy.wait(duration); + + cy.window().then((win) => { + cy.stopBenchmarking(testName, appStats.collect(win)); + }); + } else { + cy.startBenchmarking(testName); + cy.wait(duration); + cy.stopBenchmarking(testName, {}); + } + }); + }); + }); + } +}; diff --git a/e2e/scenes/utils/support/index.ts b/e2e/scenes/utils/support/index.ts new file mode 100644 index 00000000000..1ebbc3f881e --- /dev/null +++ b/e2e/scenes/utils/support/index.ts @@ -0,0 +1,4 @@ +export * from './localStorage'; +export * from './scenarioContext'; +export * from './selector'; +export * from './types'; diff --git a/e2e/scenes/utils/support/localStorage.ts b/e2e/scenes/utils/support/localStorage.ts new file mode 100644 index 00000000000..c7f8436a522 --- /dev/null +++ b/e2e/scenes/utils/support/localStorage.ts @@ -0,0 +1,17 @@ +// @todo this actually returns type `Cypress.Chainable` +const get = (key: string) => + cy.wrap({ getLocalStorage: () => localStorage.getItem(key) }, { log: false }).invoke('getLocalStorage'); + +export const getLocalStorage = (key: string) => + get(key).then((value) => { + if (value === null) { + return value; + } else { + return JSON.parse(value); + } + }); + +export const requireLocalStorage = (key: string) => + get(key) // `getLocalStorage()` would turn 'null' into `null` + .should('not.equal', null) + .then((value) => JSON.parse(value)); diff --git a/e2e/scenes/utils/support/monaco.ts b/e2e/scenes/utils/support/monaco.ts new file mode 100644 index 00000000000..54c528877ed --- /dev/null +++ b/e2e/scenes/utils/support/monaco.ts @@ -0,0 +1,7 @@ +import { e2e } from '../index'; + +export function waitForMonacoToLoad() { + e2e.components.QueryField.container().children('[data-testid="Spinner"]').should('not.exist'); + cy.window().its('monaco').should('exist'); + cy.get('.monaco-editor textarea:first').should('exist'); +} diff --git a/e2e/scenes/utils/support/scenarioContext.ts b/e2e/scenes/utils/support/scenarioContext.ts new file mode 100644 index 00000000000..15fcc01ed32 --- /dev/null +++ b/e2e/scenes/utils/support/scenarioContext.ts @@ -0,0 +1,57 @@ +import { DeleteDashboardConfig } from '../flows/deleteDashboard'; +import { DeleteDataSourceConfig } from '../flows/deleteDataSource'; + +export interface ScenarioContext { + addedDashboards: DeleteDashboardConfig[]; + addedDataSources: DeleteDataSourceConfig[]; + lastAddedDashboard: string; // @todo rename to `lastAddedDashboardTitle` + lastAddedDashboardUid: string; + lastAddedDataSource: string; // @todo rename to `lastAddedDataSourceName` + lastAddedDataSourceId: string; + hasChangedUserPreferences: boolean; +} + +const scenarioContext: ScenarioContext = { + addedDashboards: [], + addedDataSources: [], + hasChangedUserPreferences: false, + get lastAddedDashboard() { + return lastProperty(this.addedDashboards, 'title'); + }, + get lastAddedDashboardUid() { + return lastProperty(this.addedDashboards, 'uid'); + }, + get lastAddedDataSource() { + return lastProperty(this.addedDataSources, 'name'); + }, + get lastAddedDataSourceId() { + return lastProperty(this.addedDataSources, 'id'); + }, +}; + +const lastProperty = ( + items: T[], + key: K +) => items[items.length - 1]?.[key] ?? ''; + +export const getScenarioContext = (): Cypress.Chainable => + cy + .wrap( + { + getScenarioContext: (): ScenarioContext => ({ ...scenarioContext }), + }, + { log: false } + ) + .invoke({ log: false }, 'getScenarioContext'); + +export const setScenarioContext = (newContext: Partial): Cypress.Chainable => + cy + .wrap( + { + setScenarioContext: () => { + Object.assign(scenarioContext, newContext); + }, + }, + { log: false } + ) + .invoke({ log: false }, 'setScenarioContext'); diff --git a/e2e/scenes/utils/support/selector.ts b/e2e/scenes/utils/support/selector.ts new file mode 100644 index 00000000000..8e49d54afa1 --- /dev/null +++ b/e2e/scenes/utils/support/selector.ts @@ -0,0 +1,11 @@ +export interface SelectorApi { + fromAriaLabel: (selector: string) => string; + fromDataTestId: (selector: string) => string; + fromSelector: (selector: string) => string; +} + +export const Selector: SelectorApi = { + fromAriaLabel: (selector: string) => `[aria-label="${selector}"]`, + fromDataTestId: (selector: string) => `[data-testid="${selector}"]`, + fromSelector: (selector: string) => selector, +}; diff --git a/e2e/scenes/utils/support/types.ts b/e2e/scenes/utils/support/types.ts new file mode 100644 index 00000000000..e7ccfe91db7 --- /dev/null +++ b/e2e/scenes/utils/support/types.ts @@ -0,0 +1,136 @@ +import { CssSelector, FunctionSelector, Selectors, StringSelector, UrlSelector } from '@grafana/e2e-selectors'; + +import { Selector } from './selector'; +import { fromBaseUrl } from './url'; + +export type VisitFunction = (args?: string, queryParams?: object) => Cypress.Chainable; +export type E2EVisit = { visit: VisitFunction }; +export type E2EFunction = ((text?: string, options?: CypressOptions) => Cypress.Chainable>) & + E2EFunctionWithOnlyOptions; +export type E2EFunctionWithOnlyOptions = (options?: CypressOptions) => Cypress.Chainable>; + +export type TypeSelectors = S extends StringSelector + ? E2EFunctionWithOnlyOptions + : S extends FunctionSelector + ? E2EFunction + : S extends CssSelector + ? E2EFunction + : S extends UrlSelector + ? E2EVisit & Omit, 'url'> + : S extends Record + ? E2EFunctions + : S; + +export type E2EFunctions = { + [P in keyof S]: TypeSelectors; +}; + +export type E2EObjects = E2EFunctions; + +export type E2EFactoryArgs = { selectors: S }; + +export type CypressOptions = Partial; + +const processSelectors = (e2eObjects: E2EFunctions, selectors: S): E2EFunctions => { + const logOutput = (data: unknown) => cy.logToConsole('Retrieving Selector:', data); + const keys = Object.keys(selectors); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]; + const value = selectors[key]; + + if (key === 'url') { + // @ts-ignore + e2eObjects['visit'] = (args?: string, queryParams?: object) => { + let parsedUrl = ''; + if (typeof value === 'string') { + parsedUrl = fromBaseUrl(value); + } + + if (typeof value === 'function' && args) { + parsedUrl = fromBaseUrl(value(args)); + } + + cy.logToConsole('Visiting', parsedUrl); + if (queryParams) { + return cy.visit({ url: parsedUrl, qs: queryParams }); + } else { + return cy.visit(parsedUrl); + } + }; + + continue; + } + + if (typeof value === 'string') { + // @ts-ignore + e2eObjects[key] = (options?: CypressOptions) => { + logOutput(value); + const selector = value.startsWith('data-testid') + ? Selector.fromDataTestId(value) + : Selector.fromAriaLabel(value); + + return cy.get(selector, options); + }; + + continue; + } + + if (typeof value === 'function') { + // @ts-ignore + e2eObjects[key] = function (textOrOptions?: string | CypressOptions, options?: CypressOptions) { + // the input can only be () + if (arguments.length === 0) { + const selector = value(''); + + logOutput(selector); + return cy.get(selector); + } + + // the input can be (text) or (options) + if (arguments.length === 1) { + if (typeof textOrOptions === 'string') { + const selectorText = value(textOrOptions); + const selector = selectorText.startsWith('data-testid') + ? Selector.fromDataTestId(selectorText) + : Selector.fromAriaLabel(selectorText); + + logOutput(selector); + return cy.get(selector); + } + const selector = value(''); + + logOutput(selector); + return cy.get(selector, textOrOptions); + } + + // the input can only be (text, options) + if (arguments.length === 2 && typeof textOrOptions === 'string') { + const text = textOrOptions; + const selectorText = value(text); + const selector = text.startsWith('data-testid') + ? Selector.fromDataTestId(selectorText) + : Selector.fromAriaLabel(selectorText); + + logOutput(selector); + return cy.get(selector, options); + } + }; + + continue; + } + + if (typeof value === 'object') { + // @ts-ignore + e2eObjects[key] = processSelectors({}, value); + } + } + + return e2eObjects; +}; + +export const e2eFactory = ({ selectors }: E2EFactoryArgs): E2EObjects => { + const e2eObjects: E2EFunctions = {} as E2EFunctions; + processSelectors(e2eObjects, selectors); + + return { ...e2eObjects }; +}; diff --git a/e2e/scenes/utils/support/url.ts b/e2e/scenes/utils/support/url.ts new file mode 100644 index 00000000000..34620974b06 --- /dev/null +++ b/e2e/scenes/utils/support/url.ts @@ -0,0 +1,14 @@ +import { e2e } from '../index'; + +const getBaseUrl = () => Cypress.env('BASE_URL') || Cypress.config().baseUrl || 'http://localhost:3000'; + +export const fromBaseUrl = (url = '') => new URL(url, getBaseUrl()).href; + +export const getDashboardUid = (url: string): string => { + const matches = new URL(url).pathname.match(/\/d\/([^/]+)/); + if (!matches) { + throw new Error(`Couldn't parse uid from ${url}`); + } else { + return matches[1]; + } +}; diff --git a/e2e/scenes/utils/typings/index.ts b/e2e/scenes/utils/typings/index.ts new file mode 100644 index 00000000000..d4a4805a21f --- /dev/null +++ b/e2e/scenes/utils/typings/index.ts @@ -0,0 +1 @@ +export { undo } from './undo'; diff --git a/e2e/scenes/utils/typings/undo.ts b/e2e/scenes/utils/typings/undo.ts new file mode 100644 index 00000000000..9ee3c1cda40 --- /dev/null +++ b/e2e/scenes/utils/typings/undo.ts @@ -0,0 +1,19 @@ +// https://nodejs.org/api/os.html#os_os_platform +enum Platform { + osx = 'darwin', + windows = 'win32', + linux = 'linux', + aix = 'aix', + freebsd = 'freebsd', + openbsd = 'openbsd', + sunos = 'sunos', +} + +export const undo = () => { + switch (Cypress.platform) { + case Platform.osx: + return '{cmd}z'; + default: + return '{ctrl}z'; + } +}; diff --git a/package.json b/package.json index 49930305b1e..b46c9f643be 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:nominify": "yarn run build -- --env noMinify=1", "dev": "NODE_ENV=dev nx exec -- webpack --config scripts/webpack/webpack.dev.js", "e2e": "./e2e/start-and-run-suite", + "e2e:scenes": "./e2e/start-and-run-suite scenes", "e2e:debug": "./e2e/start-and-run-suite debug", "e2e:dev": "./e2e/start-and-run-suite dev", "e2e:benchmark:live": "./e2e/start-and-run-suite benchmark live", diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index bd8d01da9de..0902bf6b94d 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -212,6 +212,11 @@ export const Components = { rcContentWrapper: () => '.rc-drawer-content-wrapper', subtitle: 'data-testid drawer subtitle', }, + DashboardSaveDrawer: { + saveButton: 'data-testid Save dashboard drawer button', + saveAsButton: 'data-testid Save as dashboard drawer button', + saveAsTitleInput: 'Save dashboard title field', + }, }, PanelEditor: { General: { @@ -361,7 +366,25 @@ export const Components = { }, NavToolbar: { container: 'data-testid Nav toolbar', + shareDashboard: 'data-testid Share dashboard', + markAsFavorite: 'data-testid Mark as favorite', + editDashboard: { + editButton: 'data-testid Edit dashboard button', + saveButton: 'data-testid Save dashboard button', + exitButton: 'data-testid Exit edit mode button', + settingsButton: 'data-testid Dashboard settings', + addRowButton: 'data-testid Add row button', + addLibraryPanelButton: 'data-testid Add a panel from the panel library button', + addVisualizationButton: 'data-testid Add new visualization menu item', + pastePanelButton: 'data-testid Paste panel button', + discardChangesButton: 'data-testid Discard changes button', + discardLibraryPanelButton: 'data-testid Discard library panel button', + unlinkLibraryPanelButton: 'data-testid Unlink library panel button', + saveLibraryPanelButton: 'data-testid Save library panel button', + backToDashboardButton: 'data-testid Back to dashboard button', + }, }, + PageToolbar: { container: () => '.page-toolbar', item: (tooltip: string) => `${tooltip}`, diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index abda6b077c4..c687b2033b4 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -57,6 +57,7 @@ export const Pages = { navV2: 'data-testid Dashboard navigation', publicDashboardTag: 'data-testid public dashboard tag', shareButton: 'data-testid share-button', + scrollContainer: 'data-testid Dashboard canvas scroll container', playlistControls: { prev: 'data-testid playlist previous dashboard button', stop: 'data-testid playlist stop dashboard button', @@ -258,6 +259,9 @@ export const Pages = { ReshareLink: 'data-testid public dashboard reshare link button', }, }, + PublicDashboardScene: { + Tab: 'Tab Public Dashboard', + }, }, PublicDashboard: { page: 'public-dashboard-page', diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx index edc3677e1b1..0a9fb858acc 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -2,6 +2,7 @@ import debounce from 'debounce-promise'; import React, { ChangeEvent } from 'react'; import { UseFormSetValue, useForm } from 'react-hook-form'; +import { selectors } from '@grafana/e2e-selectors'; import { Dashboard } from '@grafana/schema'; import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; @@ -97,6 +98,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { ) => { setValue('title', e.target.value, { shouldValidate: true }); }, 400)} diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx index 8aa5e9f9cc4..9deedbb5fb0 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.test.tsx @@ -122,7 +122,7 @@ describe('SaveDashboardDrawer', () => { mockSaveDashboard(); - await userEvent.click(await screen.findByTestId(selectors.pages.SaveDashboardModal.save)); + await userEvent.click(await screen.findByTestId(selectors.components.Drawer.DashboardSaveDrawer.saveButton)); const dataSent = saveDashboardMutationMock.mock.calls[0][0]; expect(dataSent.dashboard.title).toEqual('New title'); @@ -140,13 +140,13 @@ describe('SaveDashboardDrawer', () => { mockSaveDashboard({ saveError: 'version-mismatch' }); - await userEvent.click(await screen.findByTestId(selectors.pages.SaveDashboardModal.save)); + await userEvent.click(await screen.findByTestId(selectors.components.Drawer.DashboardSaveDrawer.saveButton)); expect(await screen.findByText('Someone else has updated this dashboard')).toBeInTheDocument(); expect(await screen.findByText('Save and overwrite')).toBeInTheDocument(); // Now save and overwrite - await userEvent.click(await screen.findByTestId(selectors.pages.SaveDashboardModal.save)); + await userEvent.click(await screen.findByTestId(selectors.components.Drawer.DashboardSaveDrawer.saveButton)); const dataSent = saveDashboardMutationMock.mock.calls[1][0]; expect(dataSent.overwrite).toEqual(true); @@ -162,7 +162,7 @@ describe('SaveDashboardDrawer', () => { mockSaveDashboard(); - await userEvent.click(await screen.findByTestId(selectors.pages.SaveDashboardModal.save)); + await userEvent.click(await screen.findByTestId(selectors.components.Drawer.DashboardSaveDrawer.saveButton)); const dataSent = saveDashboardMutationMock.mock.calls[0][0]; expect(dataSent.dashboard.uid).toEqual(''); diff --git a/public/app/features/dashboard-scene/saving/shared.tsx b/public/app/features/dashboard-scene/saving/shared.tsx index 2681b9c1f0f..29b9f3a70b1 100644 --- a/public/app/features/dashboard-scene/saving/shared.tsx +++ b/public/app/features/dashboard-scene/saving/shared.tsx @@ -65,9 +65,9 @@ export function SaveButton({ overwrite, isLoading, isValid, onSave }: SaveButton diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 694c002720b..74345295c6d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { SceneComponentProps } from '@grafana/scenes'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -62,7 +63,11 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps )} - +
<>{isEmpty && emptyState} {withPanels} diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 49de18e6231..c9fba9cf2e4 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -89,6 +89,7 @@ export function ToolbarActions({ dashboard }: Props) { } key="star-dashboard-button" + data-testid={selectors.components.NavToolbar.markAsFavorite} onClick={() => { DashboardInteractions.toolbarFavoritesClick(); dashboard.onStarDashboard(); @@ -274,6 +275,7 @@ export function ToolbarActions({ dashboard }: Props) { variant="secondary" size="sm" icon="arrow-left" + data-testid={selectors.components.NavToolbar.editDashboard.backToDashboardButton} > Back to dashboard @@ -294,6 +296,7 @@ export function ToolbarActions({ dashboard }: Props) { variant="secondary" size="sm" icon="arrow-left" + data-testid={selectors.components.NavToolbar.editDashboard.backToDashboardButton} > Back to dashboard @@ -314,6 +317,7 @@ export function ToolbarActions({ dashboard }: Props) { DashboardInteractions.toolbarShareClick(); dashboard.showModal(new ShareModal({ dashboardRef: dashboard.getRef() })); }} + data-testid={selectors.components.NavToolbar.shareDashboard} > Share @@ -333,6 +337,7 @@ export function ToolbarActions({ dashboard }: Props) { className={styles.buttonWithExtraMargin} variant="primary" size="sm" + data-testid={selectors.components.NavToolbar.editDashboard.editButton} > Edit @@ -352,6 +357,7 @@ export function ToolbarActions({ dashboard }: Props) { size="sm" key="settings" variant="secondary" + data-testid={selectors.components.NavToolbar.editDashboard.settingsButton} > Settings @@ -369,6 +375,7 @@ export function ToolbarActions({ dashboard }: Props) { key="discard" fill="text" variant="primary" + data-testid={selectors.components.NavToolbar.editDashboard.exitButton} > Exit edit @@ -386,6 +393,7 @@ export function ToolbarActions({ dashboard }: Props) { key="discard" fill="outline" variant="destructive" + data-testid={selectors.components.NavToolbar.editDashboard.discardChangesButton} > Discard panel changes @@ -403,6 +411,7 @@ export function ToolbarActions({ dashboard }: Props) { key="discardLibraryPanel" fill="outline" variant="destructive" + data-testid={selectors.components.NavToolbar.editDashboard.discardChangesButton} > Discard library panel changes @@ -420,6 +429,7 @@ export function ToolbarActions({ dashboard }: Props) { key="unlinkLibraryPanel" fill="outline" variant="secondary" + data-testid={selectors.components.NavToolbar.editDashboard.unlinkLibraryPanelButton} > Unlink library panel @@ -437,6 +447,7 @@ export function ToolbarActions({ dashboard }: Props) { key="saveLibraryPanel" fill="outline" variant="primary" + data-testid={selectors.components.NavToolbar.editDashboard.saveLibraryPanelButton} > Save library panel @@ -460,6 +471,7 @@ export function ToolbarActions({ dashboard }: Props) { key="save" size="sm" variant={'primary'} + data-testid={selectors.components.NavToolbar.editDashboard.saveButton} > Save dashboard @@ -516,6 +528,7 @@ export function ToolbarActions({ dashboard }: Props) { }} tooltip="Save changes" size="sm" + data-testid={selectors.components.NavToolbar.editDashboard.saveButton} variant={isDirty ? 'primary' : 'secondary'} > Save dashboard