From c7419de2e597d03ad7ae6acaae64c97452577e9a Mon Sep 17 00:00:00 2001 From: juanicabanas Date: Thu, 22 Sep 2022 09:35:04 -0300 Subject: [PATCH] PublicDashboard: Add RTK Query with loading and error state. Add MSW dependency for testing. (#55518) PublicDashboard: Add RTK Query with loading and error state. Add MSW dependency for testing. --- .betterer.results | 4 +- .../dashboard-public-create.spec.ts | 5 + package.json | 1 + public/app/core/reducers/root.ts | 2 + .../dashboard/api/publicDashboardApi.ts | 56 ++++ .../components/ShareModal/ShareModal.tsx | 2 +- .../ShareModal/SharePublicDashboard.test.tsx | 146 --------- .../ShareModal/SharePublicDashboard.tsx | 286 ------------------ .../AcknowledgeCheckboxes.tsx | 79 +++++ .../SharePublicDashboard/Configuration.tsx | 66 ++++ .../SharePublicDashboard/Description.tsx | 22 ++ .../SharePublicDashboard.test.tsx | 283 +++++++++++++++++ .../SharePublicDashboard.tsx | 203 +++++++++++++ .../SharePublicDashboardUtils.test.tsx | 17 +- .../SharePublicDashboardUtils.ts | 43 +++ .../ShareModal/SharePublicDashboardUtils.ts | 83 ----- public/app/features/dashboard/utils/screen.ts | 7 + public/app/store/configureStore.ts | 4 +- yarn.lock | 267 ++++++++++++++-- 19 files changed, 1016 insertions(+), 560 deletions(-) create mode 100644 public/app/features/dashboard/api/publicDashboardApi.ts delete mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx delete mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard/AcknowledgeCheckboxes.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard/Configuration.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard/Description.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx create mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx rename public/app/features/dashboard/components/ShareModal/{ => SharePublicDashboard}/SharePublicDashboardUtils.test.tsx (72%) create mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.ts delete mode 100644 public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts create mode 100644 public/app/features/dashboard/utils/screen.ts diff --git a/.betterer.results b/.betterer.results index f6447e1ca38..56879eda5fd 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1,5 +1,5 @@ // BETTERER RESULTS V2. -// +// // If this file contains merge conflicts, use `betterer merge` to automatically resolve them: // https://phenomnomnominal.github.io/betterer/docs/results-file/#merge // @@ -3782,7 +3782,7 @@ exports[`better eslint`] = { "public/app/features/dashboard/components/ShareModal/ShareModal.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx:5381": [ + "public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/dashboard/components/ShareModal/ShareSnapshot.tsx:5381": [ diff --git a/e2e/dashboards-suite/dashboard-public-create.spec.ts b/e2e/dashboards-suite/dashboard-public-create.spec.ts index f6adc4f461e..c1818ba5fe0 100644 --- a/e2e/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e/dashboards-suite/dashboard-public-create.spec.ts @@ -28,8 +28,11 @@ e2e.scenario({ 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.SaveConfigButton().should('be.disabled'); + // Switch on enabling toggle e2e.pages.ShareDashboardModal.PublicDashboard.EnableSwitch().should('be.enabled').click({ force: true }); + e2e.pages.ShareDashboardModal.PublicDashboard.SaveConfigButton().should('be.enabled'); // Save configuration e2e().intercept('POST', '/api/dashboards/uid/ZqZnVvFZz/public-config').as('save'); @@ -69,6 +72,8 @@ e2e.scenario({ e2e.pages.ShareDashboardModal.PublicDashboard.Tab().click(); e2e().wait('@query-public-config'); + e2e.pages.ShareDashboardModal.PublicDashboard.SaveConfigButton().should('be.enabled'); + // Make a request to public dashboards api endpoint without authentication e2e.pages.ShareDashboardModal.PublicDashboard.CopyUrlInput() .invoke('val') diff --git a/package.json b/package.json index 7baf529fd92..3017052c166 100644 --- a/package.json +++ b/package.json @@ -207,6 +207,7 @@ "lerna": "5.2.0", "lint-staged": "13.0.3", "mini-css-extract-plugin": "2.6.1", + "msw": "^0.47.3", "mutationobserver-shim": "0.3.7", "ngtemplate-loader": "2.1.0", "node-notifier": "10.0.1", diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 7cc067b2a86..3c1c7e0b188 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -4,6 +4,7 @@ import sharedReducers from 'app/core/reducers'; import ldapReducers from 'app/features/admin/state/reducers'; import alertingReducers from 'app/features/alerting/state/reducers'; import apiKeysReducers from 'app/features/api-keys/state/reducers'; +import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi'; import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers'; import dashboardReducers from 'app/features/dashboard/state/reducers'; import dataSourcesReducers from 'app/features/datasources/state/reducers'; @@ -46,6 +47,7 @@ const rootReducers = { ...searchQueryReducer, plugins: pluginsReducer, [alertingApi.reducerPath]: alertingApi.reducer, + [publicDashboardApi.reducerPath]: publicDashboardApi.reducer, }; const addedReducers = {}; diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts new file mode 100644 index 00000000000..92ced69c517 --- /dev/null +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -0,0 +1,56 @@ +import { BaseQueryFn, createApi, retry } from '@reduxjs/toolkit/query/react'; +import { lastValueFrom } from 'rxjs'; + +import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime/src'; +import { notifyApp } from 'app/core/actions'; +import { createSuccessNotification } from 'app/core/copy/appNotification'; +import { PublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; +import { DashboardModel } from 'app/features/dashboard/state'; + +const backendSrvBaseQuery = + ({ baseUrl }: { baseUrl: string } = { baseUrl: '' }): BaseQueryFn => + async (requestOptions) => { + try { + const { data: responseData, ...meta } = await lastValueFrom( + getBackendSrv().fetch({ ...requestOptions, url: baseUrl + requestOptions.url }) + ); + return { data: responseData, meta }; + } catch (error) { + return { error }; + } + }; + +export const publicDashboardApi = createApi({ + reducerPath: 'publicDashboardApi', + baseQuery: retry(backendSrvBaseQuery({ baseUrl: '/api/dashboards' }), { maxRetries: 3 }), + tagTypes: ['Config'], + keepUnusedDataFor: 0, + endpoints: (builder) => ({ + getConfig: builder.query({ + query: (dashboardUid) => ({ + url: `/uid/${dashboardUid}/public-config`, + }), + providesTags: ['Config'], + }), + saveConfig: builder.mutation({ + query: (params) => ({ + url: `/uid/${params.dashboard.uid}/public-config`, + method: 'POST', + data: params.payload, + }), + async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) { + const { data } = await queryFulfilled; + dispatch(notifyApp(createSuccessNotification('Dashboard sharing configuration saved'))); + + // Update runtime meta flag + dashboard.updateMeta({ + publicDashboardUid: data.uid, + publicDashboardEnabled: data.isEnabled, + }); + }, + invalidatesTags: ['Config'], + }), + }), +}); + +export const { useGetConfigQuery, useSaveConfigMutation } = publicDashboardApi; diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index 3990a14183a..f7554d5f561 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -5,6 +5,7 @@ import { reportInteraction } from '@grafana/runtime/src'; import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui'; import { config } from 'app/core/config'; import { contextSrv } from 'app/core/core'; +import { SharePublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard'; import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard'; @@ -12,7 +13,6 @@ import { ShareEmbed } from './ShareEmbed'; import { ShareExport } from './ShareExport'; import { ShareLibraryPanel } from './ShareLibraryPanel'; import { ShareLink } from './ShareLink'; -import { SharePublicDashboard } from './SharePublicDashboard'; import { ShareSnapshot } from './ShareSnapshot'; import { ShareModalTabModel } from './types'; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx deleted file mode 100644 index e6da5d32eb8..00000000000 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import React from 'react'; - -import { BootData } from '@grafana/data'; -import { BackendSrv, setEchoSrv } from '@grafana/runtime'; -import config from 'app/core/config'; -import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; - -import { Echo } from '../../../../core/services/echo/Echo'; - -import { ShareModal } from './ShareModal'; -import { PublicDashboard } from './SharePublicDashboardUtils'; - -// Mock api request -const publicDashboardconfigResp: PublicDashboard = { - isEnabled: true, - uid: '', - dashboardUid: '', - accessToken: '', -}; - -const backendSrv = { - get: () => publicDashboardconfigResp, -} as unknown as BackendSrv; - -jest.mock('@grafana/runtime', () => ({ - ...(jest.requireActual('@grafana/runtime') as unknown as object), - getBackendSrv: () => backendSrv, -})); - -jest.mock('app/core/core', () => { - return { - contextSrv: { - hasPermission: () => true, - }, - appEvents: { - subscribe: () => { - return { - unsubscribe: () => {}, - }; - }, - emit: () => {}, - }, - }; -}); - -describe('SharePublic', () => { - let originalBootData: BootData; - - beforeAll(() => { - setEchoSrv(new Echo()); - originalBootData = config.bootData; - config.appUrl = 'http://dashboards.grafana.com/'; - - config.bootData = { - user: { - orgId: 1, - }, - } as any; - }); - - afterAll(() => { - config.bootData = originalBootData; - }); - - it('does not render share panel when public dashboards feature is disabled', () => { - const mockDashboard = new DashboardModel({ - uid: 'mockDashboardUid', - }); - const mockPanel = new PanelModel({ - id: 'mockPanelId', - }); - - render( {}} />); - - expect(screen.getByRole('tablist')).toHaveTextContent('Link'); - expect(screen.getByRole('tablist')).not.toHaveTextContent('Public dashboard'); - }); - - it('renders share panel when public dashboards feature is enabled', async () => { - config.featureToggles.publicDashboards = true; - const mockDashboard = new DashboardModel({ - uid: 'mockDashboardUid', - }); - const mockPanel = new PanelModel({ - id: 'mockPanelId', - }); - - render( {}} />); - - await waitFor(() => screen.getByText('Link')); - expect(screen.getByRole('tablist')).toHaveTextContent('Link'); - expect(screen.getByRole('tablist')).toHaveTextContent('Public dashboard'); - - fireEvent.click(screen.getByText('Public dashboard')); - - await screen.findByText('Welcome to Grafana public dashboards alpha!'); - }); - - it('renders default relative time in input', async () => { - config.featureToggles.publicDashboards = true; - const mockDashboard = new DashboardModel({ - uid: 'mockDashboardUid', - }); - const mockPanel = new PanelModel({ - id: 'mockPanelId', - }); - - expect(mockDashboard.time).toEqual({ from: 'now-6h', to: 'now' }); - - //@ts-ignore - mockDashboard.originalTime = { from: 'now-6h', to: 'now' }; - - render( {}} />); - - await waitFor(() => screen.getByText('Link')); - fireEvent.click(screen.getByText('Public dashboard')); - - await screen.findByText('Welcome to Grafana public dashboards alpha!'); - expect(screen.getByText('Last 6 hours')).toBeInTheDocument(); - }); - - it('renders default absolute time in input 2', async () => { - config.featureToggles.publicDashboards = true; - const mockDashboard = new DashboardModel({ - uid: 'mockDashboardUid', - }); - const mockPanel = new PanelModel({ - id: 'mockPanelId', - }); - - mockDashboard.time = { from: '2022-08-30T03:00:00.000Z', to: '2022-09-04T02:59:59.000Z' }; - //@ts-ignore - mockDashboard.originalTime = { from: '2022-08-30T06:00:00.000Z', to: '2022-09-04T06:59:59.000Z' }; - - render( {}} />); - - await waitFor(() => screen.getByText('Link')); - fireEvent.click(screen.getByText('Public dashboard')); - - await screen.findByText('Welcome to Grafana public dashboards alpha!'); - expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 01:59:59')).toBeInTheDocument(); - }); - - // test checking if current version of dashboard in state is persisted to db -}); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx deleted file mode 100644 index c4fb590c65a..00000000000 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useCallback, useEffect, useState } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data/src'; -import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; -import { reportInteraction } from '@grafana/runtime/src'; -import { - Alert, - Button, - Checkbox, - ClipboardButton, - Field, - HorizontalGroup, - FieldSet, - Input, - Label, - LinkButton, - Switch, - TimeRangeInput, - useStyles2, - VerticalGroup, -} from '@grafana/ui'; -import { notifyApp } from 'app/core/actions'; -import { createErrorNotification } from 'app/core/copy/appNotification'; -import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; -import { dispatch } from 'app/store/store'; - -import { contextSrv } from '../../../../core/services/context_srv'; -import { AccessControlAction } from '../../../../types'; -import { isOrgAdmin } from '../../../plugins/admin/permissions'; - -import { - dashboardHasTemplateVariables, - generatePublicDashboardUrl, - getPublicDashboardConfig, - PublicDashboard, - publicDashboardPersisted, - savePublicDashboardConfig, -} from './SharePublicDashboardUtils'; -import { ShareModalTabProps } from './types'; - -interface Props extends ShareModalTabProps {} - -interface Acknowledgements { - public: boolean; - datasources: boolean; - usage: boolean; -} - -export const SharePublicDashboard = (props: Props) => { - const dashboardVariables = props.dashboard.getVariables(); - const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; - const styles = useStyles2(getStyles); - - const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin()); - - const [publicDashboard, setPublicDashboardConfig] = useState({ - isEnabled: false, - uid: '', - dashboardUid: props.dashboard.uid, - }); - const [acknowledgements, setAcknowledgements] = useState({ - public: false, - datasources: false, - usage: false, - }); - - const timeRange = getTimeRange(props.dashboard.getDefaultTime(), props.dashboard); - - useEffect(() => { - reportInteraction('grafana_dashboards_public_share_viewed'); - - getPublicDashboardConfig(props.dashboard.uid, setPublicDashboardConfig).catch(); - }, [props.dashboard.uid]); - - useEffect(() => { - if (publicDashboardPersisted(publicDashboard)) { - setAcknowledgements({ - public: true, - datasources: true, - usage: true, - }); - } - }, [publicDashboard]); - - const onSavePublicConfig = () => { - reportInteraction('grafana_dashboards_public_create_clicked'); - - if (dashboardHasTemplateVariables(dashboardVariables)) { - dispatch( - notifyApp(createErrorNotification('This dashboard cannot be made public because it has template variables')) - ); - return; - } - - savePublicDashboardConfig(props.dashboard, publicDashboard, setPublicDashboardConfig).catch(); - }; - - const onAcknowledge = useCallback( - (field: string, checked: boolean) => { - setAcknowledgements({ ...acknowledgements, [field]: checked }); - }, - [acknowledgements] - ); - - // check if all conditions have been acknowledged - const acknowledged = acknowledgements.public && acknowledgements.datasources && acknowledgements.usage; - - return ( - <> -

Welcome to Grafana public dashboards alpha!

- {dashboardHasTemplateVariables(dashboardVariables) ? ( - - This dashboard cannot be made public because it has template variables - - ) : ( - <> -

- To allow the current dashboard to be published publicly, toggle the switch. For now we do not support - template variables or frontend datasources. -

-

- We'd love your feedback. To share, please comment on this{' '} - - GitHub discussion - - . -

-
-
-

Before you click Save, please acknowledge the following information:

-
- - - onAcknowledge('public', e.currentTarget.checked)} - /> - - - - onAcknowledge('datasources', e.currentTarget.checked)} - /> - - - - onAcknowledge('usage', e.currentTarget.checked)} - /> - - - -
-
-
-
-

Public dashboard configuration

-
- - - - {}} /> - - - - { - reportInteraction('grafana_dashboards_public_enable_clicked', { - action: publicDashboard?.isEnabled ? 'disable' : 'enable', - }); - - setPublicDashboardConfig({ - ...publicDashboard, - isEnabled: !publicDashboard.isEnabled, - }); - }} - /> - - {publicDashboardPersisted(publicDashboard) && publicDashboard.isEnabled && ( - - generatePublicDashboardUrl(publicDashboard)} - > - Copy - - } - /> - - )} - -
- {hasWritePermissions ? ( - props.dashboard.hasUnsavedChanges() && ( - - ) - ) : ( - - )} - -
- - )} - - ); -}; - -const getStyles = (theme: GrafanaTheme2) => ({ - checkboxes: css` - margin: ${theme.spacing(2, 0)}; - `, - timeRange: css` - padding: ${theme.spacing(1, 1)}; - margin: ${theme.spacing(0, 0, 2, 0)}; - `, - dashboardConfig: css` - margin: ${theme.spacing(0, 0, 3, 0)}; - `, - publicUrl: css` - width: 100%; - margin-bottom: 0; - `, -}); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/AcknowledgeCheckboxes.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/AcknowledgeCheckboxes.tsx new file mode 100644 index 00000000000..af5950db425 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/AcknowledgeCheckboxes.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { Checkbox, FieldSet, HorizontalGroup, LinkButton, VerticalGroup } from '@grafana/ui/src'; + +import { Acknowledgements } from './SharePublicDashboardUtils'; + +export const AcknowledgeCheckboxes = ({ + disabled, + acknowledgements, + onAcknowledge, +}: { + disabled: boolean; + acknowledgements: Acknowledgements; + onAcknowledge: (key: string, val: boolean) => void; +}) => { + const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; + + return ( + <> +

Before you click Save, please acknowledge the following information:

+
+ + + onAcknowledge('public', e.currentTarget.checked)} + /> + + + + onAcknowledge('datasources', e.currentTarget.checked)} + /> + + + + onAcknowledge('usage', e.currentTarget.checked)} + /> + + + +
+ + ); +}; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/Configuration.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/Configuration.tsx new file mode 100644 index 00000000000..46cf7fc7eca --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/Configuration.tsx @@ -0,0 +1,66 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { reportInteraction } from '@grafana/runtime/src'; +import { FieldSet, Label, Switch, TimeRangeInput, useStyles2, VerticalGroup } from '@grafana/ui/src'; +import { Layout } from '@grafana/ui/src/components/Layout/Layout'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { useIsDesktop } from 'app/features/dashboard/utils/screen'; +import { getTimeRange } from 'app/features/dashboard/utils/timeRange'; + +export const Configuration = ({ + disabled, + isPubDashEnabled, + hasTemplateVariables, + onToggleEnabled, + dashboard, +}: { + disabled: boolean; + isPubDashEnabled?: boolean; + onToggleEnabled: () => void; + hasTemplateVariables: boolean; + dashboard: DashboardModel; +}) => { + const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; + const styles = useStyles2(getStyles); + const isDesktop = useIsDesktop(); + + const timeRange = getTimeRange(dashboard.getDefaultTime(), dashboard); + + return ( + <> +

Public dashboard configuration

+
+ + + + {}} /> + + + + { + reportInteraction('grafana_dashboards_public_enable_clicked', { + action: isPubDashEnabled ? 'disable' : 'enable', + }); + + onToggleEnabled(); + }} + /> + + +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + dashboardConfig: css` + margin: ${theme.spacing(0, 0, 3, 0)}; + `, +}); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/Description.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/Description.tsx new file mode 100644 index 00000000000..0fdd76aac65 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/Description.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export const Description = () => ( + <> +

+ To allow the current dashboard to be published publicly, toggle the switch. For now we do not support template + variables or frontend datasources. +

+

+ We'd love your feedback. To share, please comment on this{' '} + + GitHub discussion + + . +

+ +); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx new file mode 100644 index 00000000000..690fc241f97 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -0,0 +1,283 @@ +import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import 'whatwg-fetch'; +import { BootData } from '@grafana/data/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { setEchoSrv } from '@grafana/runtime/src'; +import config from 'app/core/config'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { contextSrv } from 'app/core/services/context_srv'; +import { Echo } from 'app/core/services/echo/Echo'; +import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; +import { configureStore } from 'app/store/configureStore'; + +import { ShareModal } from '../ShareModal'; + +const server = setupServer( + rest.get('/api/dashboards/uid/:uId/public-config', (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + isEnabled: false, + uid: undefined, + dashboardUid: undefined, + accessToken: 'an-access-token', + }) + ); + }) +); + +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as unknown as object), + getBackendSrv: () => backendSrv, +})); + +const renderSharePublicDashboard = async (props: React.ComponentProps, isEnabled = true) => { + const store = configureStore(); + + render( + + + + ); + + await waitFor(() => screen.getByText('Link')); + isEnabled && fireEvent.click(screen.getByText('Public dashboard')); +}; + +const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; + +let originalBootData: BootData; +let mockDashboard: DashboardModel; +let mockPanel: PanelModel; + +beforeAll(() => { + setEchoSrv(new Echo()); + originalBootData = config.bootData; + config.appUrl = 'http://dashboards.grafana.com/'; + config.bootData = { + user: { + orgId: 1, + }, + navTree: [ + { + text: 'Section name', + id: 'section', + url: 'section', + children: [ + { text: 'Child1', id: 'child1', url: 'section/child1' }, + { text: 'Child2', id: 'child2', url: 'section/child2' }, + ], + }, + ], + } as any; + + server.listen({ onUnhandledRequest: 'bypass' }); +}); + +beforeEach(() => { + config.featureToggles.publicDashboards = true; + mockDashboard = new DashboardModel({ + uid: 'mockDashboardUid', + }); + + mockPanel = new PanelModel({ + id: 'mockPanelId', + }); + + jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true); + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); + jest.spyOn(contextSrv, 'hasRole').mockReturnValue(true); +}); + +afterAll(() => { + config.bootData = originalBootData; + server.close(); +}); + +afterEach(() => { + jest.restoreAllMocks(); + server.resetHandlers(); +}); + +describe('SharePublic', () => { + it('does not render share panel when public dashboards feature is disabled', async () => { + config.featureToggles.publicDashboards = false; + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }, false); + + expect(screen.getByRole('tablist')).toHaveTextContent('Link'); + expect(screen.getByRole('tablist')).not.toHaveTextContent('Public dashboard'); + }); + + it('renders share panel when public dashboards feature is enabled', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + + expect(screen.getByRole('tablist')).toHaveTextContent('Link'); + expect(screen.getByRole('tablist')).toHaveTextContent('Public dashboard'); + + fireEvent.click(screen.getByText('Public dashboard')); + + await screen.findByText('Welcome to Grafana public dashboards alpha!'); + }); + + it('renders default relative time in input', async () => { + expect(mockDashboard.time).toEqual({ from: 'now-6h', to: 'now' }); + + //@ts-ignore + mockDashboard.originalTime = { from: 'now-6h', to: 'now' }; + + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + + await screen.findByText('Welcome to Grafana public dashboards alpha!'); + expect(screen.getByText('Last 6 hours')).toBeInTheDocument(); + }); + it('renders default absolute time in input 2', async () => { + mockDashboard.time = { from: '2022-08-30T03:00:00.000Z', to: '2022-09-04T02:59:59.000Z' }; + //@ts-ignore + mockDashboard.originalTime = { from: '2022-08-30T06:00:00.000Z', to: '2022-09-04T06:59:59.000Z' }; + + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + + await screen.findByText('Welcome to Grafana public dashboards alpha!'); + expect(screen.getByText('2022-08-30 00:00:00 to 2022-09-04 01:59:59')).toBeInTheDocument(); + }); + it('when modal is opened, then loader spinner appears and inputs are disabled', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + + expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); + + expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeDisabled(); + expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeDisabled(); + expect(screen.getByTestId(selectors.CostIncreaseCheckbox)).toBeDisabled(); + expect(screen.getByTestId(selectors.EnableSwitch)).toBeDisabled(); + expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); + }); + it('when fetch errors happen, then all inputs remain disabled', async () => { + server.use( + rest.get('/api/dashboards/uid/:uId/public-config', (req, res, ctx) => { + return res(ctx.status(500)); + }) + ); + + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner'), { timeout: 7000 }); + + expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeDisabled(); + expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeDisabled(); + expect(screen.getByTestId(selectors.CostIncreaseCheckbox)).toBeDisabled(); + expect(screen.getByTestId(selectors.EnableSwitch)).toBeDisabled(); + expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); + }); + // test checking if current version of dashboard in state is persisted to db +}); + +describe('SharePublic - New config setup', () => { + it('when modal is opened, then save button is disabled', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); + }); + it('when fetch is done, then loader spinner is gone, inputs are enabled and save button is disabled', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + + expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeEnabled(); + expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeEnabled(); + expect(screen.getByTestId(selectors.CostIncreaseCheckbox)).toBeEnabled(); + expect(screen.getByTestId(selectors.EnableSwitch)).toBeEnabled(); + + expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); + }); + it('when checkboxes are filled, then save button remains disabled', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + + fireEvent.click(screen.getByTestId(selectors.WillBePublicCheckbox)); + fireEvent.click(screen.getByTestId(selectors.LimitedDSCheckbox)); + fireEvent.click(screen.getByTestId(selectors.CostIncreaseCheckbox)); + + expect(screen.getByTestId(selectors.SaveConfigButton)).toBeDisabled(); + }); + it('when checkboxes and switch are filled, then save button is enabled', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + + fireEvent.click(screen.getByTestId(selectors.WillBePublicCheckbox)); + fireEvent.click(screen.getByTestId(selectors.LimitedDSCheckbox)); + fireEvent.click(screen.getByTestId(selectors.CostIncreaseCheckbox)); + fireEvent.click(screen.getByTestId(selectors.EnableSwitch)); + + expect(screen.getByTestId(selectors.SaveConfigButton)).toBeEnabled(); + }); +}); + +describe('SharePublic - Already persisted', () => { + beforeEach(() => { + server.use( + rest.get('/api/dashboards/uid/:uId/public-config', (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + isEnabled: true, + uid: 'a-uid', + dashboardUid: req.params.uId, + accessToken: 'an-access-token', + }) + ); + }) + ); + }); + + it('when modal is opened, then save button is enabled', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + + expect(screen.getByTestId(selectors.SaveConfigButton)).toBeEnabled(); + }); + it('when fetch is done, then loader spinner is gone, inputs are disabled and save button is enabled', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + + expect(screen.getByTestId(selectors.WillBePublicCheckbox)).toBeDisabled(); + expect(screen.getByTestId(selectors.LimitedDSCheckbox)).toBeDisabled(); + expect(screen.getByTestId(selectors.CostIncreaseCheckbox)).toBeDisabled(); + + expect(screen.getByTestId(selectors.EnableSwitch)).toBeEnabled(); + expect(screen.getByTestId(selectors.SaveConfigButton)).toBeEnabled(); + }); + it('when pubdash is enabled, then link url is available', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + expect(screen.getByTestId(selectors.CopyUrlInput)).toBeInTheDocument(); + }); + it('when pubdash is disabled in the db, then link url is not available', async () => { + server.use( + rest.get('/api/dashboards/uid/:uId/public-config', (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + isEnabled: false, + uid: 'a-uid', + dashboardUid: req.params.uId, + accessToken: 'an-access-token', + }) + ); + }) + ); + + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + + expect(screen.queryByTestId(selectors.CopyUrlInput)).not.toBeInTheDocument(); + }); + it('when pubdash is disabled by the user, then link url is not available', async () => { + await renderSharePublicDashboard({ panel: mockPanel, dashboard: mockDashboard, onDismiss: () => {} }); + await waitForElementToBeRemoved(screen.getByTestId('Spinner')); + + fireEvent.click(screen.getByTestId(selectors.EnableSwitch)); + expect(screen.queryByTestId(selectors.CopyUrlInput)).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx new file mode 100644 index 00000000000..fab54be0e4f --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.tsx @@ -0,0 +1,203 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data/src'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; +import { reportInteraction } from '@grafana/runtime/src'; +import { Alert, Button, ClipboardButton, Field, HorizontalGroup, Input, useStyles2, Spinner } from '@grafana/ui/src'; +import { notifyApp } from 'app/core/actions'; +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { contextSrv } from 'app/core/services/context_srv'; +import { useGetConfigQuery, useSaveConfigMutation } from 'app/features/dashboard/api/publicDashboardApi'; +import { AcknowledgeCheckboxes } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/AcknowledgeCheckboxes'; +import { Configuration } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/Configuration'; +import { Description } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/Description'; +import { + Acknowledgements, + dashboardHasTemplateVariables, + generatePublicDashboardUrl, + publicDashboardPersisted, +} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; +import { ShareModalTabProps } from 'app/features/dashboard/components/ShareModal/types'; +import { isOrgAdmin } from 'app/features/plugins/admin/permissions'; +import { dispatch } from 'app/store/store'; +import { AccessControlAction } from 'app/types'; + +interface Props extends ShareModalTabProps {} + +export const SharePublicDashboard = (props: Props) => { + const dashboardVariables = props.dashboard.getVariables(); + const selectors = e2eSelectors.pages.ShareDashboardModal.PublicDashboard; + const styles = useStyles2(getStyles); + + const { + isLoading: isFetchingLoading, + data: publicDashboard, + isError: isFetchingError, + } = useGetConfigQuery(props.dashboard.uid); + + const [saveConfig, { isLoading: isSaveLoading }] = useSaveConfigMutation(); + + const [acknowledgements, setAcknowledgements] = useState({ + public: false, + datasources: false, + usage: false, + }); + const [enabledSwitch, setEnabledSwitch] = useState({ + isEnabled: false, + wasTouched: false, + }); + + useEffect(() => { + reportInteraction('grafana_dashboards_public_share_viewed'); + }, []); + + useEffect(() => { + if (publicDashboardPersisted(publicDashboard)) { + setAcknowledgements({ + public: true, + datasources: true, + usage: true, + }); + } + + setEnabledSwitch((prevState) => ({ ...prevState, isEnabled: !!publicDashboard?.isEnabled })); + }, [publicDashboard]); + + const isLoading = isFetchingLoading || isSaveLoading; + const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin()); + const acknowledged = acknowledgements.public && acknowledgements.datasources && acknowledgements.usage; + const isSaveEnabled = useMemo( + () => + !hasWritePermissions || + !acknowledged || + props.dashboard.hasUnsavedChanges() || + isLoading || + isFetchingError || + (!publicDashboardPersisted(publicDashboard) && !enabledSwitch.wasTouched), + [hasWritePermissions, acknowledged, props.dashboard, isLoading, isFetchingError, enabledSwitch, publicDashboard] + ); + + const onSavePublicConfig = () => { + reportInteraction('grafana_dashboards_public_create_clicked'); + + if (dashboardHasTemplateVariables(dashboardVariables)) { + dispatch( + notifyApp(createErrorNotification('This dashboard cannot be made public because it has template variables')) + ); + return; + } + + saveConfig({ + dashboard: props.dashboard, + payload: { ...publicDashboard!, isEnabled: enabledSwitch.isEnabled }, + }); + }; + + const onAcknowledge = (field: string, checked: boolean) => { + setAcknowledgements((prevState) => ({ ...prevState, [field]: checked })); + }; + + return ( + <> + +

+ Welcome to Grafana public dashboards alpha! +

+ {isFetchingLoading && } +
+
+ {dashboardHasTemplateVariables(dashboardVariables) ? ( + + This dashboard cannot be made public because it has template variables + + ) : ( + <> + +
+
+ +
+
+ + setEnabledSwitch((prevState) => ({ isEnabled: !prevState.isEnabled, wasTouched: true })) + } + hasTemplateVariables={dashboardHasTemplateVariables(dashboardVariables)} + /> + {publicDashboardPersisted(publicDashboard) && enabledSwitch.isEnabled && ( + + generatePublicDashboardUrl(publicDashboard!)} + > + Copy + + } + /> + + )} + {hasWritePermissions ? ( + props.dashboard.hasUnsavedChanges() && ( + + ) + ) : ( + + )} + + + {isSaveLoading && } + + + )} +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + content: css` + margin: ${theme.spacing(1, 0, 0, 0)}; + `, + checkboxes: css` + margin: ${theme.spacing(2, 0)}; + `, + timeRange: css` + padding: ${theme.spacing(1, 1)}; + margin: ${theme.spacing(0, 0, 2, 0)}; + `, + publicUrl: css` + width: 100%; + margin-bottom: ${theme.spacing(0, 0, 3, 0)}; + `, +}); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx similarity index 72% rename from public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx rename to public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx index fcb0593ab8e..3278190b022 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx @@ -1,14 +1,11 @@ +import { updateConfig } from 'app/core/config'; import { VariableModel } from 'app/features/variables/types'; -import { updateConfig } from '../../../../core/config'; - import { PublicDashboard, dashboardHasTemplateVariables, generatePublicDashboardUrl, publicDashboardPersisted, - getPublicDashboardConfigUrl, - savePublicDashboardConfigUrl, } from './SharePublicDashboardUtils'; describe('dashboardHasTemplateVariables', () => { @@ -48,15 +45,3 @@ describe('publicDashboardPersisted', () => { expect(publicDashboardPersisted(pubdash)).toBe(false); }); }); - -describe('getPublicDashboardConfigUrl', () => { - it('builds the correct url', () => { - expect(getPublicDashboardConfigUrl('abc1234')).toEqual('/api/dashboards/uid/abc1234/public-config'); - }); -}); - -describe('savePublicDashboardConfigUrl', () => { - it('builds the correct url', () => { - expect(savePublicDashboardConfigUrl('abc1234')).toEqual('/api/dashboards/uid/abc1234/public-config'); - }); -}); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.ts b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.ts new file mode 100644 index 00000000000..1cbfc9f50c5 --- /dev/null +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.ts @@ -0,0 +1,43 @@ +import { getConfig } from 'app/core/config'; +import { VariableModel } from 'app/features/variables/types'; +import { DashboardDataDTO, DashboardMeta } from 'app/types/dashboard'; + +export interface PublicDashboard { + accessToken?: string; + isEnabled: boolean; + uid: string; + dashboardUid: string; + timeSettings?: object; +} + +export interface DashboardResponse { + dashboard: DashboardDataDTO; + meta: DashboardMeta; +} + +export interface Acknowledgements { + public: boolean; + datasources: boolean; + usage: boolean; +} + +// Instance methods +export const dashboardHasTemplateVariables = (variables: VariableModel[]): boolean => { + return variables.length > 0; +}; + +export const publicDashboardPersisted = (publicDashboard?: PublicDashboard): boolean => { + return publicDashboard?.uid !== '' && publicDashboard?.uid !== undefined; +}; + +/** + * Generate the public dashboard url. Uses the appUrl from the Grafana boot config, so urls will also be correct + * when Grafana is hosted on a subpath. + * + * All app urls from the Grafana boot config end with a slash. + * + * @param publicDashboard + */ +export const generatePublicDashboardUrl = (publicDashboard: PublicDashboard): string => { + return `${getConfig().appUrl}public-dashboards/${publicDashboard.accessToken}`; +}; diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts b/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts deleted file mode 100644 index 8902f1e80d0..00000000000 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboardUtils.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { getBackendSrv } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; -import { getConfig } from 'app/core/config'; -import { createSuccessNotification } from 'app/core/copy/appNotification'; -import { VariableModel } from 'app/features/variables/types'; -import { dispatch } from 'app/store/store'; -import { DashboardDataDTO, DashboardMeta } from 'app/types/dashboard'; - -import { DashboardModel } from '../../state'; - -export interface PublicDashboard { - accessToken?: string; - isEnabled: boolean; - uid: string; - dashboardUid: string; - timeSettings?: object; -} - -export interface DashboardResponse { - dashboard: DashboardDataDTO; - meta: DashboardMeta; -} - -export const getPublicDashboardConfig = async ( - dashboardUid: string, - setPublicDashboard: React.Dispatch> -) => { - const pdResp: PublicDashboard = await getBackendSrv().get(getPublicDashboardConfigUrl(dashboardUid)); - setPublicDashboard(pdResp); -}; - -export const savePublicDashboardConfig = async ( - dashboard: DashboardModel, - publicDashboardConfig: PublicDashboard, - setPublicDashboard: React.Dispatch> -) => { - const pdResp: PublicDashboard = await getBackendSrv().post( - savePublicDashboardConfigUrl(dashboard.uid), - publicDashboardConfig - ); - - // Never allow a user to send the orgId - // @ts-ignore - delete pdResp.orgId; - - dispatch(notifyApp(createSuccessNotification('Dashboard sharing configuration saved'))); - setPublicDashboard(pdResp); - - // Update runtime emta flag - dashboard.updateMeta({ - publicDashboardUid: pdResp.uid, - publicDashboardEnabled: publicDashboardConfig.isEnabled, - }); -}; - -export const getPublicDashboardConfigUrl = (dashboardUid: string) => { - return `/api/dashboards/uid/${dashboardUid}/public-config`; -}; - -export const savePublicDashboardConfigUrl = (dashboardUid: string) => { - return `/api/dashboards/uid/${dashboardUid}/public-config`; -}; - -// Instance methods -export const dashboardHasTemplateVariables = (variables: VariableModel[]): boolean => { - return variables.length > 0; -}; - -export const publicDashboardPersisted = (publicDashboard: PublicDashboard): boolean => { - return publicDashboard.uid !== '' && publicDashboard.uid !== undefined; -}; - -/** - * Generate the public dashboard url. Uses the appUrl from the Grafana boot config, so urls will also be correct - * when Grafana is hosted on a subpath. - * - * All app urls from the Grafana boot config end with a slash. - * - * @param publicDashboard - */ -export const generatePublicDashboardUrl = (publicDashboard: PublicDashboard): string => { - return `${getConfig().appUrl}public-dashboards/${publicDashboard.accessToken}`; -}; diff --git a/public/app/features/dashboard/utils/screen.ts b/public/app/features/dashboard/utils/screen.ts new file mode 100644 index 00000000000..99e944f629c --- /dev/null +++ b/public/app/features/dashboard/utils/screen.ts @@ -0,0 +1,7 @@ +import { useWindowSize } from 'react-use'; + +export const useIsDesktop = () => { + const { width } = useWindowSize(); + + return width > 1024; +}; diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 58f199349fd..30e2c0a7164 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -1,5 +1,6 @@ import { configureStore as reduxConfigureStore } from '@reduxjs/toolkit'; +import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi'; import { StoreState } from 'app/types/store'; import { buildInitialState } from '../core/reducers/navModel'; @@ -20,7 +21,8 @@ export function configureStore(initialState?: Partial) { reducer: createRootReducer(), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat( - alertingApi.middleware + alertingApi.middleware, + publicDashboardApi.middleware ), devTools: process.env.NODE_ENV !== 'production', preloadedState: { diff --git a/yarn.lock b/yarn.lock index 9097b02d12b..5d5a4d24eba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7682,6 +7682,32 @@ __metadata: languageName: node linkType: hard +"@mswjs/cookies@npm:^0.2.2": + version: 0.2.2 + resolution: "@mswjs/cookies@npm:0.2.2" + dependencies: + "@types/set-cookie-parser": ^2.4.0 + set-cookie-parser: ^2.4.6 + checksum: 23b1ef56d57efcc1b44600076f531a1fb703855af342a31e01bad4adaf0dab51f6d3b5595a95a7988c3f612ba075835f9a06c52833205284d101eb9a51dd72b0 + languageName: node + linkType: hard + +"@mswjs/interceptors@npm:^0.17.5": + version: 0.17.5 + resolution: "@mswjs/interceptors@npm:0.17.5" + dependencies: + "@open-draft/until": ^1.0.3 + "@types/debug": ^4.1.7 + "@xmldom/xmldom": ^0.7.5 + debug: ^4.3.3 + headers-polyfill: ^3.1.0 + outvariant: ^1.2.1 + strict-event-emitter: ^0.2.4 + web-encoding: ^1.1.5 + checksum: 0293ccc56c1c85fb7334cd5902574f7df20c26be74d633c83fde64ffd7620f81e08253fe7985c6b5ad3b64c04ad53c3610e9b9c07621518aabd977343026bb2b + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -8091,6 +8117,13 @@ __metadata: languageName: node linkType: hard +"@open-draft/until@npm:^1.0.3": + version: 1.0.3 + resolution: "@open-draft/until@npm:1.0.3" + checksum: 323e92ebef0150ed0f8caedc7d219b68cdc50784fa4eba0377eef93533d3f46514eb2400ced83dda8c51bddc3d2c7b8e9cf95e5ec85ab7f62dfc015d174f62f2 + languageName: node + linkType: hard + "@opentelemetry/api-metrics@npm:0.25.0": version: 0.25.0 resolution: "@opentelemetry/api-metrics@npm:0.25.0" @@ -11076,6 +11109,13 @@ __metadata: languageName: node linkType: hard +"@types/cookie@npm:^0.4.1": + version: 0.4.1 + resolution: "@types/cookie@npm:0.4.1" + checksum: 3275534ed69a76c68eb1a77d547d75f99fedc80befb75a3d1d03662fb08d697e6f8b1274e12af1a74c6896071b11510631ba891f64d30c78528d0ec45a9c1a18 + languageName: node + linkType: hard + "@types/d3-array@npm:*": version: 3.0.2 resolution: "@types/d3-array@npm:3.0.2" @@ -11417,6 +11457,15 @@ __metadata: languageName: node linkType: hard +"@types/debug@npm:^4.1.7": + version: 4.1.7 + resolution: "@types/debug@npm:4.1.7" + dependencies: + "@types/ms": "*" + checksum: 0a7b89d8ed72526858f0b61c6fd81f477853e8c4415bb97f48b1b5545248d2ae389931680b94b393b993a7cfe893537a200647d93defe6d87159b96812305adc + languageName: node + linkType: hard + "@types/deep-freeze@npm:^0.1.1": version: 0.1.2 resolution: "@types/deep-freeze@npm:0.1.2" @@ -11785,6 +11834,13 @@ __metadata: languageName: node linkType: hard +"@types/js-levenshtein@npm:^1.1.1": + version: 1.1.1 + resolution: "@types/js-levenshtein@npm:1.1.1" + checksum: 1d1ff1ee2ad551909e47f3ce19fcf85b64dc5146d3b531c8d26fc775492d36e380b32cf5ef68ff301e812c3b00282f37aac579ebb44498b94baff0ace7509769 + languageName: node + linkType: hard + "@types/js-yaml@npm:^4.0.5": version: 4.0.5 resolution: "@types/js-yaml@npm:4.0.5" @@ -11928,6 +11984,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 0.7.31 + resolution: "@types/ms@npm:0.7.31" + checksum: daadd354aedde024cce6f5aa873fefe7b71b22cd0e28632a69e8b677aeb48ae8caa1c60e5919bb781df040d116b01cb4316335167a3fc0ef6a63fa3614c0f6da + languageName: node + linkType: hard + "@types/node-fetch@npm:^2.5.7": version: 2.6.1 resolution: "@types/node-fetch@npm:2.6.1" @@ -12485,6 +12548,15 @@ __metadata: languageName: node linkType: hard +"@types/set-cookie-parser@npm:^2.4.0": + version: 2.4.2 + resolution: "@types/set-cookie-parser@npm:2.4.2" + dependencies: + "@types/node": "*" + checksum: c31bf04eb9620829dc3c91bced74ac934ad039d20d20893fb5acac0f08769cbd4eef3bf7502a0289c7be59c3e9cfa456147b4e88bff47dd1b9efb4995ba5d5a3 + languageName: node + linkType: hard + "@types/sinon@npm:10.0.13": version: 10.0.13 resolution: "@types/sinon@npm:10.0.13" @@ -13625,6 +13697,13 @@ __metadata: languageName: node linkType: hard +"@xmldom/xmldom@npm:^0.7.5": + version: 0.7.5 + resolution: "@xmldom/xmldom@npm:0.7.5" + checksum: 8d7ec35c1ef6183b4f621df08e01d7e61f244fb964a4719025e65fe6ac06fac418919be64fb40fe5908e69158ef728f2d936daa082db326fe04603012b5f2a84 + languageName: node + linkType: hard + "@xobotyi/scrollbar-width@npm:^1.9.5": version: 1.9.5 resolution: "@xobotyi/scrollbar-width@npm:1.9.5" @@ -13646,6 +13725,13 @@ __metadata: languageName: node linkType: hard +"@zxing/text-encoding@npm:0.9.0": + version: 0.9.0 + resolution: "@zxing/text-encoding@npm:0.9.0" + checksum: c23b12aee7639382e4949961304a1294776afaffa40f579e09ffecd0e5e68cf26ef3edd75009de46da8a536e571448755ca68b3e2ea707d53793c0edb2e2c34a + languageName: node + linkType: hard + "JSONStream@npm:^1.0.4": version: 1.3.5 resolution: "JSONStream@npm:1.3.5" @@ -16077,6 +16163,16 @@ __metadata: languageName: node linkType: hard +"chalk@npm:4.1.1": + version: 4.1.1 + resolution: "chalk@npm:4.1.1" + dependencies: + ansi-styles: ^4.1.0 + supports-color: ^7.1.0 + checksum: 036e973e665ba1a32c975e291d5f3d549bceeb7b1b983320d4598fb75d70fe20c5db5d62971ec0fe76cdbce83985a00ee42372416abfc3a5584465005a7855ed + languageName: node + linkType: hard + "chalk@npm:^2.0.0, chalk@npm:^2.0.1, chalk@npm:^2.1.0, chalk@npm:^2.3.0, chalk@npm:^2.3.2, chalk@npm:^2.4.1": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -17101,7 +17197,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.4.2": +"cookie@npm:0.4.2, cookie@npm:^0.4.2": version: 0.4.2 resolution: "cookie@npm:0.4.2" checksum: a00833c998bedf8e787b4c342defe5fa419abd96b32f4464f718b91022586b8f1bafbddd499288e75c037642493c83083da426c6a9080d309e3bd90fd11baa9b @@ -22450,6 +22546,7 @@ __metadata: mousetrap: 1.6.5 mousetrap-global-bind: 1.1.0 moveable: 0.35.4 + msw: ^0.47.3 mutationobserver-shim: 0.3.7 ngtemplate-loader: 2.1.0 node-notifier: 10.0.1 @@ -22555,6 +22652,13 @@ __metadata: languageName: node linkType: hard +"graphql@npm:^15.0.0 || ^16.0.0": + version: 16.6.0 + resolution: "graphql@npm:16.6.0" + checksum: bf1d9e3c1938ce3c1a81e909bd3ead1ae4707c577f91cff1ca2eca474bfbc7873d5d7b942e1e9777ff5a8304421dba57a4b76d7a29eb19de8711cb70e3c2415e + languageName: node + linkType: hard + "growly@npm:^1.3.0": version: 1.3.0 resolution: "growly@npm:1.3.0" @@ -22861,6 +22965,13 @@ __metadata: languageName: node linkType: hard +"headers-polyfill@npm:^3.1.0": + version: 3.1.0 + resolution: "headers-polyfill@npm:3.1.0" + checksum: a95257065684653b7185efbb9a380c547ea832002991b5adf0d90cd222073da2701be9dc2849d1970ecf15e8c35b383984358566afe6e76ca8ff1dbd7cdce3af + languageName: node + linkType: hard + "highlight-words-core@npm:^1.2.0": version: 1.2.2 resolution: "highlight-words-core@npm:1.2.2" @@ -23689,29 +23800,7 @@ __metadata: languageName: node linkType: hard -"inquirer@npm:^8.2.2": - version: 8.2.2 - resolution: "inquirer@npm:8.2.2" - dependencies: - ansi-escapes: ^4.2.1 - chalk: ^4.1.1 - cli-cursor: ^3.1.0 - cli-width: ^3.0.0 - external-editor: ^3.0.3 - figures: ^3.0.0 - lodash: ^4.17.21 - mute-stream: 0.0.8 - ora: ^5.4.1 - run-async: ^2.4.0 - rxjs: ^7.5.5 - string-width: ^4.1.0 - strip-ansi: ^6.0.0 - through: ^2.3.6 - checksum: 69a2cf32f51af0e94dd66c597fdca42b890ff521b537dbfe1fd532c19a751d54893b7896523691ec30357f6212a80a2417fec7bf34411f369bbf151bdbc95ae9 - languageName: node - linkType: hard - -"inquirer@npm:^8.2.4": +"inquirer@npm:^8.2.0, inquirer@npm:^8.2.4": version: 8.2.4 resolution: "inquirer@npm:8.2.4" dependencies: @@ -23734,6 +23823,28 @@ __metadata: languageName: node linkType: hard +"inquirer@npm:^8.2.2": + version: 8.2.2 + resolution: "inquirer@npm:8.2.2" + dependencies: + ansi-escapes: ^4.2.1 + chalk: ^4.1.1 + cli-cursor: ^3.1.0 + cli-width: ^3.0.0 + external-editor: ^3.0.3 + figures: ^3.0.0 + lodash: ^4.17.21 + mute-stream: 0.0.8 + ora: ^5.4.1 + run-async: ^2.4.0 + rxjs: ^7.5.5 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + through: ^2.3.6 + checksum: 69a2cf32f51af0e94dd66c597fdca42b890ff521b537dbfe1fd532c19a751d54893b7896523691ec30357f6212a80a2417fec7bf34411f369bbf151bdbc95ae9 + languageName: node + linkType: hard + "internal-slot@npm:^1.0.3": version: 1.0.3 resolution: "internal-slot@npm:1.0.3" @@ -24219,6 +24330,13 @@ __metadata: languageName: node linkType: hard +"is-node-process@npm:^1.0.1": + version: 1.0.1 + resolution: "is-node-process@npm:1.0.1" + checksum: 3ddb8a892a00f6eb9c2aea7e7e1426b8683512d9419933d95114f4f64b5455e26601c23a31c0682463890032136dd98a326988a770ab6b4eed54a43ade8bed50 + languageName: node + linkType: hard + "is-number-object@npm:^1.0.4": version: 1.0.6 resolution: "is-number-object@npm:1.0.6" @@ -25884,6 +26002,13 @@ __metadata: languageName: node linkType: hard +"js-levenshtein@npm:^1.1.6": + version: 1.1.6 + resolution: "js-levenshtein@npm:1.1.6" + checksum: 409f052a7f1141be4058d97da7860e08efd97fc588b7a4c5cfa0548bc04f6d576644dae65ab630266dff685d56fb90d494e03d4d79cb484c287746b4f1bf0694 + languageName: node + linkType: hard + "js-string-escape@npm:^1.0.1": version: 1.0.1 resolution: "js-string-escape@npm:1.0.1" @@ -28055,6 +28180,41 @@ __metadata: languageName: node linkType: hard +"msw@npm:^0.47.3": + version: 0.47.3 + resolution: "msw@npm:0.47.3" + dependencies: + "@mswjs/cookies": ^0.2.2 + "@mswjs/interceptors": ^0.17.5 + "@open-draft/until": ^1.0.3 + "@types/cookie": ^0.4.1 + "@types/js-levenshtein": ^1.1.1 + chalk: 4.1.1 + chokidar: ^3.4.2 + cookie: ^0.4.2 + graphql: ^15.0.0 || ^16.0.0 + headers-polyfill: ^3.1.0 + inquirer: ^8.2.0 + is-node-process: ^1.0.1 + js-levenshtein: ^1.1.6 + node-fetch: ^2.6.7 + outvariant: ^1.3.0 + path-to-regexp: ^6.2.0 + statuses: ^2.0.0 + strict-event-emitter: ^0.2.0 + type-fest: ^2.19.0 + yargs: ^17.3.1 + peerDependencies: + typescript: ">= 4.2.x <= 4.8.x" + peerDependenciesMeta: + typescript: + optional: true + bin: + msw: cli/index.js + checksum: 1be018c7b2eff982409967cccb5c604e45f65710ee9698bab57fbe794f8426d1a4d33e52b75ef395c6d226948c799241c7c2c7748ec4f5b741e7f25bcbafbd1e + languageName: node + linkType: hard + "multicast-dns@npm:^7.2.4": version: 7.2.4 resolution: "multicast-dns@npm:7.2.4" @@ -29187,6 +29347,13 @@ __metadata: languageName: node linkType: hard +"outvariant@npm:^1.2.1, outvariant@npm:^1.3.0": + version: 1.3.0 + resolution: "outvariant@npm:1.3.0" + checksum: ac76ca375c1c642989e1c74f0e9ebac84c05bc9fdc8f28be949c16fae1658e9f1f2fb1133fe3cc1e98afabef78fe4298fe9360b5734baf8e6ad440c182680848 + languageName: node + linkType: hard + "overlap-area@npm:^1.1.0": version: 1.1.0 resolution: "overlap-area@npm:1.1.0" @@ -29731,6 +29898,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^6.2.0": + version: 6.2.1 + resolution: "path-to-regexp@npm:6.2.1" + checksum: f0227af8284ea13300f4293ba111e3635142f976d4197f14d5ad1f124aebd9118783dd2e5f1fe16f7273743cc3dbeddfb7493f237bb27c10fdae07020cc9b698 + languageName: node + linkType: hard + "path-type@npm:^3.0.0": version: 3.0.0 resolution: "path-type@npm:3.0.0" @@ -34645,6 +34819,13 @@ __metadata: languageName: node linkType: hard +"set-cookie-parser@npm:^2.4.6": + version: 2.5.1 + resolution: "set-cookie-parser@npm:2.5.1" + checksum: b99c37f976e68ae6eb7c758bf2bbce1e60bb54e3eccedaa25f2da45b77b9cab58d90674cf9edd7aead6fbeac6308f2eb48713320a47ca120d0e838d0194513b6 + languageName: node + linkType: hard + "set-harmonic-interval@npm:^1.0.1": version: 1.0.1 resolution: "set-harmonic-interval@npm:1.0.1" @@ -35567,6 +35748,13 @@ __metadata: languageName: node linkType: hard +"statuses@npm:^2.0.0": + version: 2.0.1 + resolution: "statuses@npm:2.0.1" + checksum: 18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb + languageName: node + linkType: hard + "store2@npm:^2.12.0": version: 2.13.1 resolution: "store2@npm:2.13.1" @@ -35649,6 +35837,15 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter@npm:^0.2.0, strict-event-emitter@npm:^0.2.4": + version: 0.2.4 + resolution: "strict-event-emitter@npm:0.2.4" + dependencies: + events: ^3.3.0 + checksum: fe6af7baf4002910ffd04d16f37c994e7b9f56b4c01c8846a3b394efcea6689a9eba3ebcd5283774476c3a7632aae6b47ef89061b0fbf7f2256b8e07a5cab32d + languageName: node + linkType: hard + "string-argv@npm:^0.3.1": version: 0.3.1 resolution: "string-argv@npm:0.3.1" @@ -37274,6 +37471,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^2.19.0": + version: 2.19.0 + resolution: "type-fest@npm:2.19.0" + checksum: a4ef07ece297c9fba78fc1bd6d85dff4472fe043ede98bd4710d2615d15776902b595abf62bd78339ed6278f021235fb28a96361f8be86ed754f778973a0d278 + languageName: node + linkType: hard + "type-is@npm:~1.6.17, type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -37848,7 +38052,7 @@ __metadata: languageName: node linkType: hard -"util@npm:^0.12.0": +"util@npm:^0.12.0, util@npm:^0.12.3": version: 0.12.4 resolution: "util@npm:0.12.4" dependencies: @@ -38207,6 +38411,19 @@ __metadata: languageName: node linkType: hard +"web-encoding@npm:^1.1.5": + version: 1.1.5 + resolution: "web-encoding@npm:1.1.5" + dependencies: + "@zxing/text-encoding": 0.9.0 + util: ^0.12.3 + dependenciesMeta: + "@zxing/text-encoding": + optional: true + checksum: 2234a2b122f41006ce07859b3c0bf2e18f46144fda2907d5db0b571b76aa5c26977c646100ad9c00d2f8a4f6f2b848bc02147845d8c447ab365ec4eff376338d + languageName: node + linkType: hard + "web-namespaces@npm:^1.0.0": version: 1.1.4 resolution: "web-namespaces@npm:1.1.4"