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.
This commit is contained in:
juanicabanas
2022-09-22 09:35:04 -03:00
committed by GitHub
parent 2f14575dd3
commit c7419de2e5
19 changed files with 1016 additions and 560 deletions

View File

@@ -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';

View File

@@ -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(<ShareModal panel={mockPanel} dashboard={mockDashboard} onDismiss={() => {}} />);
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(<ShareModal panel={mockPanel} dashboard={mockDashboard} onDismiss={() => {}} />);
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(<ShareModal panel={mockPanel} dashboard={mockDashboard} onDismiss={() => {}} />);
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(<ShareModal panel={mockPanel} dashboard={mockDashboard} onDismiss={() => {}} />);
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
});

View File

@@ -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<PublicDashboard>({
isEnabled: false,
uid: '',
dashboardUid: props.dashboard.uid,
});
const [acknowledgements, setAcknowledgements] = useState<Acknowledgements>({
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 (
<>
<p>Welcome to Grafana public dashboards alpha!</p>
{dashboardHasTemplateVariables(dashboardVariables) ? (
<Alert
severity="warning"
title="dashboard cannot be public"
data-testid={selectors.TemplateVariablesWarningAlert}
>
This dashboard cannot be made public because it has template variables
</Alert>
) : (
<>
<p>
To allow the current dashboard to be published publicly, toggle the switch. For now we do not support
template variables or frontend datasources.
</p>
<p>
We&apos;d love your feedback. To share, please comment on this{' '}
<a
href="https://github.com/grafana/grafana/discussions/49253"
target="_blank"
rel="noreferrer"
className="text-link"
>
GitHub discussion
</a>
.
</p>
<hr />
<div className={styles.checkboxes}>
<p>Before you click Save, please acknowledge the following information:</p>
<FieldSet disabled={publicDashboardPersisted(publicDashboard) || !hasWritePermissions}>
<VerticalGroup spacing="md">
<HorizontalGroup spacing="none">
<Checkbox
label="Your entire dashboard will be public"
value={acknowledgements.public}
data-testid={selectors.WillBePublicCheckbox}
onChange={(e) => onAcknowledge('public', e.currentTarget.checked)}
/>
<LinkButton
variant="primary"
href="https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/"
target="_blank"
fill="text"
icon="info-circle"
rel="noopener noreferrer"
tooltip="Learn more about public dashboards"
/>
</HorizontalGroup>
<HorizontalGroup spacing="none">
<Checkbox
label="Publishing currently only works with a subset of datasources"
value={acknowledgements.datasources}
data-testid={selectors.LimitedDSCheckbox}
onChange={(e) => onAcknowledge('datasources', e.currentTarget.checked)}
/>
<LinkButton
variant="primary"
href="https://grafana.com/docs/grafana/latest/datasources/"
target="_blank"
fill="text"
icon="info-circle"
rel="noopener noreferrer"
tooltip="Learn more about public datasources"
/>
</HorizontalGroup>
<HorizontalGroup spacing="none">
<Checkbox
label="Making your dashboard public will cause queries to run each time the dashboard is viewed which may increase costs"
value={acknowledgements.usage}
data-testid={selectors.CostIncreaseCheckbox}
onChange={(e) => onAcknowledge('usage', e.currentTarget.checked)}
/>
<LinkButton
variant="primary"
href="https://grafana.com/docs/grafana/latest/enterprise/query-caching/"
target="_blank"
fill="text"
icon="info-circle"
rel="noopener noreferrer"
tooltip="Learn more about query caching"
/>
</HorizontalGroup>
</VerticalGroup>
</FieldSet>
</div>
<hr />
<div>
<h4 className="share-modal-info-text">Public dashboard configuration</h4>
<FieldSet disabled={!hasWritePermissions} className={styles.dashboardConfig}>
<VerticalGroup spacing="md">
<HorizontalGroup spacing="xs" justify="space-between">
<Label description="The public dashboard uses the default time settings of the dashboard">
Time Range
</Label>
<TimeRangeInput value={timeRange} disabled onChange={() => {}} />
</HorizontalGroup>
<HorizontalGroup spacing="xs" justify="space-between">
<Label description="Configures whether current dashboard can be available publicly">Enabled</Label>
<Switch
disabled={dashboardHasTemplateVariables(dashboardVariables)}
data-testid={selectors.EnableSwitch}
value={publicDashboard?.isEnabled}
onChange={() => {
reportInteraction('grafana_dashboards_public_enable_clicked', {
action: publicDashboard?.isEnabled ? 'disable' : 'enable',
});
setPublicDashboardConfig({
...publicDashboard,
isEnabled: !publicDashboard.isEnabled,
});
}}
/>
</HorizontalGroup>
{publicDashboardPersisted(publicDashboard) && publicDashboard.isEnabled && (
<Field label="Link URL" className={styles.publicUrl}>
<Input
value={generatePublicDashboardUrl(publicDashboard)}
readOnly
data-testid={selectors.CopyUrlInput}
addonAfter={
<ClipboardButton
data-testid={selectors.CopyUrlButton}
variant="primary"
icon="copy"
getText={() => generatePublicDashboardUrl(publicDashboard)}
>
Copy
</ClipboardButton>
}
/>
</Field>
)}
</VerticalGroup>
</FieldSet>
{hasWritePermissions ? (
props.dashboard.hasUnsavedChanges() && (
<Alert
title="Please save your dashboard changes before updating the public configuration"
severity="warning"
/>
)
) : (
<Alert title="You don't have permissions to create or update a public dashboard" severity="warning" />
)}
<Button
disabled={!hasWritePermissions || !acknowledged || props.dashboard.hasUnsavedChanges()}
onClick={onSavePublicConfig}
data-testid={selectors.SaveConfigButton}
>
Save sharing configuration
</Button>
</div>
</>
)}
</>
);
};
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;
`,
});

View File

@@ -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 (
<>
<p>Before you click Save, please acknowledge the following information:</p>
<FieldSet disabled={disabled}>
<VerticalGroup spacing="md">
<HorizontalGroup spacing="none">
<Checkbox
label="Your entire dashboard will be public"
value={acknowledgements.public}
data-testid={selectors.WillBePublicCheckbox}
onChange={(e) => onAcknowledge('public', e.currentTarget.checked)}
/>
<LinkButton
variant="primary"
href="https://grafana.com/docs/grafana/latest/dashboards/dashboard-public/"
target="_blank"
fill="text"
icon="info-circle"
rel="noopener noreferrer"
tooltip="Learn more about public dashboards"
/>
</HorizontalGroup>
<HorizontalGroup spacing="none">
<Checkbox
label="Publishing currently only works with a subset of datasources"
value={acknowledgements.datasources}
data-testid={selectors.LimitedDSCheckbox}
onChange={(e) => onAcknowledge('datasources', e.currentTarget.checked)}
/>
<LinkButton
variant="primary"
href="https://grafana.com/docs/grafana/latest/datasources/"
target="_blank"
fill="text"
icon="info-circle"
rel="noopener noreferrer"
tooltip="Learn more about public datasources"
/>
</HorizontalGroup>
<HorizontalGroup spacing="none">
<Checkbox
label="Making your dashboard public will cause queries to run each time the dashboard is viewed which may increase costs"
value={acknowledgements.usage}
data-testid={selectors.CostIncreaseCheckbox}
onChange={(e) => onAcknowledge('usage', e.currentTarget.checked)}
/>
<LinkButton
variant="primary"
href="https://grafana.com/docs/grafana/latest/enterprise/query-caching/"
target="_blank"
fill="text"
icon="info-circle"
rel="noopener noreferrer"
tooltip="Learn more about query caching"
/>
</HorizontalGroup>
</VerticalGroup>
</FieldSet>
</>
);
};

View File

@@ -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 (
<>
<h4 className="share-modal-info-text">Public dashboard configuration</h4>
<FieldSet disabled={disabled} className={styles.dashboardConfig}>
<VerticalGroup spacing="md">
<Layout orientation={isDesktop ? 0 : 1} spacing="xs" justify="space-between">
<Label description="The public dashboard uses the default time settings of the dashboard">Time range</Label>
<TimeRangeInput value={timeRange} disabled onChange={() => {}} />
</Layout>
<Layout orientation={isDesktop ? 0 : 1} spacing="xs" justify="space-between">
<Label description="Configures whether current dashboard can be available publicly">Enabled</Label>
<Switch
disabled={hasTemplateVariables}
data-testid={selectors.EnableSwitch}
value={isPubDashEnabled}
onChange={() => {
reportInteraction('grafana_dashboards_public_enable_clicked', {
action: isPubDashEnabled ? 'disable' : 'enable',
});
onToggleEnabled();
}}
/>
</Layout>
</VerticalGroup>
</FieldSet>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
dashboardConfig: css`
margin: ${theme.spacing(0, 0, 3, 0)};
`,
});

View File

@@ -0,0 +1,22 @@
import React from 'react';
export const Description = () => (
<>
<p>
To allow the current dashboard to be published publicly, toggle the switch. For now we do not support template
variables or frontend datasources.
</p>
<p>
We&apos;d love your feedback. To share, please comment on this{' '}
<a
href="https://github.com/grafana/grafana/discussions/49253"
target="_blank"
rel="noreferrer"
className="text-link"
>
GitHub discussion
</a>
.
</p>
</>
);

View File

@@ -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<typeof ShareModal>, isEnabled = true) => {
const store = configureStore();
render(
<Provider store={store}>
<ShareModal {...props} />
</Provider>
);
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();
});
});

View File

@@ -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<Acknowledgements>({
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 (
<>
<HorizontalGroup>
<p
className={css`
margin: 0;
`}
>
Welcome to Grafana public dashboards alpha!
</p>
{isFetchingLoading && <Spinner />}
</HorizontalGroup>
<div className={styles.content}>
{dashboardHasTemplateVariables(dashboardVariables) ? (
<Alert
severity="warning"
title="dashboard cannot be public"
data-testid={selectors.TemplateVariablesWarningAlert}
>
This dashboard cannot be made public because it has template variables
</Alert>
) : (
<>
<Description />
<hr />
<div className={styles.checkboxes}>
<AcknowledgeCheckboxes
disabled={
publicDashboardPersisted(publicDashboard) || !hasWritePermissions || isLoading || isFetchingError
}
acknowledgements={acknowledgements}
onAcknowledge={onAcknowledge}
/>
</div>
<hr />
<Configuration
dashboard={props.dashboard}
disabled={!hasWritePermissions || isLoading || isFetchingError}
isPubDashEnabled={enabledSwitch.isEnabled}
onToggleEnabled={() =>
setEnabledSwitch((prevState) => ({ isEnabled: !prevState.isEnabled, wasTouched: true }))
}
hasTemplateVariables={dashboardHasTemplateVariables(dashboardVariables)}
/>
{publicDashboardPersisted(publicDashboard) && enabledSwitch.isEnabled && (
<Field label="Link URL" className={styles.publicUrl}>
<Input
disabled={isLoading}
value={generatePublicDashboardUrl(publicDashboard!)}
readOnly
data-testid={selectors.CopyUrlInput}
addonAfter={
<ClipboardButton
data-testid={selectors.CopyUrlButton}
variant="primary"
icon="copy"
getText={() => generatePublicDashboardUrl(publicDashboard!)}
>
Copy
</ClipboardButton>
}
/>
</Field>
)}
{hasWritePermissions ? (
props.dashboard.hasUnsavedChanges() && (
<Alert
title="Please save your dashboard changes before updating the public configuration"
severity="warning"
/>
)
) : (
<Alert title="You don't have permissions to create or update a public dashboard" severity="warning" />
)}
<HorizontalGroup>
<Button disabled={isSaveEnabled} onClick={onSavePublicConfig} data-testid={selectors.SaveConfigButton}>
Save sharing configuration
</Button>
{isSaveLoading && <Spinner />}
</HorizontalGroup>
</>
)}
</div>
</>
);
};
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)};
`,
});

View File

@@ -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');
});
});

View File

@@ -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}`;
};

View File

@@ -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<React.SetStateAction<PublicDashboard>>
) => {
const pdResp: PublicDashboard = await getBackendSrv().get(getPublicDashboardConfigUrl(dashboardUid));
setPublicDashboard(pdResp);
};
export const savePublicDashboardConfig = async (
dashboard: DashboardModel,
publicDashboardConfig: PublicDashboard,
setPublicDashboard: React.Dispatch<React.SetStateAction<PublicDashboard>>
) => {
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}`;
};