Nested folders: Create basic Move/Delete modals (#67140)

* add modal scaffolding

* add some unit tests

* remove dummy api, add some TODO comments

* small test refactor

* another small test refactor

* fix unit tests due to aria-label/data-testid change
This commit is contained in:
Ashley Harrison 2023-04-25 17:08:40 +01:00 committed by GitHub
parent bb66f14c1d
commit e6e741546f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 436 additions and 61 deletions

View File

@ -962,9 +962,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
],
"packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],

View File

@ -38,7 +38,7 @@ export const Pages = {
dataSourcePluginsV2: (pluginName: string) => `Add new data source ${pluginName}`,
},
ConfirmModal: {
delete: 'Confirm Modal Danger Button',
delete: 'data-testid Confirm Modal Danger Button',
},
AddDashboard: {
url: '/dashboard/new',

View File

@ -1,4 +1,4 @@
import { render, screen, within } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { ConfirmModal } from './ConfirmModal';
@ -23,8 +23,7 @@ describe('ConfirmModal', () => {
expect(screen.getByText('Some Body')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Dismiss Text' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Alternative Text' })).toBeInTheDocument();
const button = screen.getByRole('button', { name: 'Confirm Modal Danger Button' });
expect(within(button).getByText('Please Confirm')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeInTheDocument();
});
it('should render nothing when isOpen is false', () => {
@ -43,6 +42,6 @@ describe('ConfirmModal', () => {
expect(screen.queryByText('Some Body')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Dismiss Text' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Alternative Text' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Confirm Modal Danger Button' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Confirm' })).not.toBeInTheDocument();
});
});

View File

@ -105,7 +105,7 @@ export const ConfirmModal = ({
onClick={onConfirm}
disabled={disabled}
ref={buttonRef}
aria-label={selectors.pages.ConfirmModal.delete}
data-testid={selectors.pages.ConfirmModal.delete}
>
{confirmText}
</Button>

View File

@ -84,7 +84,7 @@ export function Modal(props: PropsWithChildren<Props>) {
typeof title !== 'string' && title
}
<div className={styles.modalHeaderClose}>
<IconButton aria-label="Close dialogue" name="times" size="xl" onClick={onDismiss} />
<IconButton aria-label="Close dialog" name="times" size="xl" onClick={onDismiss} />
</div>
</div>
<div className={cx(styles.modalContent, contentClassName)}>{children}</div>

View File

@ -75,7 +75,7 @@ const dataSources = {
};
const ui = {
confirmButton: byRole('button', { name: /Confirm Modal Danger Button/ }),
confirmButton: byRole('button', { name: /Yes, reset configuration/ }),
resetButton: byRole('button', { name: /Reset configuration/ }),
saveButton: byRole('button', { name: /Save/ }),
configInput: byLabelText<HTMLTextAreaElement>(/Configuration/),

View File

@ -12,7 +12,7 @@ import { buildNavModel } from '../folders/state/navModel';
import { parseRouteParams } from '../search/utils';
import { skipToken, useGetFolderQuery } from './api/browseDashboardsAPI';
import { BrowseActions } from './components/BrowseActions';
import { BrowseActions } from './components/BrowseActions/BrowseActions';
import { BrowseFilters } from './components/BrowseFilters';
import { BrowseView } from './components/BrowseView';
import { SearchView } from './components/SearchView';

View File

@ -1,41 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, useStyles2 } from '@grafana/ui';
export interface Props {}
export function BrowseActions() {
const styles = useStyles2(getStyles);
const onMove = () => {
// TODO real implemenation, stub for now
console.log('onMoveClicked');
};
const onDelete = () => {
// TODO real implementation, stub for now
console.log('onDeleteClicked');
};
return (
<div className={styles.row} data-testid="manage-actions">
<Button onClick={onMove} variant="secondary">
Move
</Button>
<Button onClick={onDelete} variant="destructive">
Delete
</Button>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
row: css({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
marginBottom: theme.spacing(2),
}),
});

View File

@ -1,8 +1,13 @@
import { render, screen } from '@testing-library/react';
import { render as rtlRender, screen } from '@testing-library/react';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { BrowseActions } from './BrowseActions';
function render(...[ui, options]: Parameters<typeof rtlRender>) {
rtlRender(<TestProvider>{ui}</TestProvider>, options);
}
describe('browse-dashboards BrowseActions', () => {
it('displays Move and Delete buttons', () => {
render(<BrowseActions />);

View File

@ -0,0 +1,67 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, useStyles2 } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events';
import { useSelectedItemsState } from '../../state';
import { DeleteModal } from './DeleteModal';
import { MoveModal } from './MoveModal';
export interface Props {}
export function BrowseActions() {
const styles = useStyles2(getStyles);
const selectedItems = useSelectedItemsState();
const onMove = () => {
appEvents.publish(
new ShowModalReactEvent({
component: MoveModal,
props: {
selectedItems,
onConfirm: (moveTarget: string) => {
console.log(`MoveModal onConfirm clicked with target ${moveTarget}!`);
},
},
})
);
};
const onDelete = () => {
appEvents.publish(
new ShowModalReactEvent({
component: DeleteModal,
props: {
selectedItems,
onConfirm: () => {
console.log('DeleteModal onConfirm clicked!');
},
},
})
);
};
return (
<div className={styles.row} data-testid="manage-actions">
<Button onClick={onMove} variant="secondary">
Move
</Button>
<Button onClick={onDelete} variant="destructive">
Delete
</Button>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
row: css({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
marginBottom: theme.spacing(2),
}),
});

View File

@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { DeleteModal, Props } from './DeleteModal';
describe('browse-dashboards DeleteModal', () => {
const mockOnDismiss = jest.fn();
const mockOnConfirm = jest.fn();
const defaultProps: Props = {
isOpen: true,
onConfirm: mockOnConfirm,
onDismiss: mockOnDismiss,
selectedItems: {
folder: {},
dashboard: {},
panel: {},
},
};
it('renders a dialog with the correct title', async () => {
render(<DeleteModal {...defaultProps} />);
expect(await screen.findByRole('dialog', { name: 'Delete Compute Resources' })).toBeInTheDocument();
});
it('displays a `Delete` button', async () => {
render(<DeleteModal {...defaultProps} />);
expect(await screen.findByRole('button', { name: 'Delete' })).toBeInTheDocument();
});
it('displays a `Cancel` button', async () => {
render(<DeleteModal {...defaultProps} />);
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
it('only enables the `Delete` button if the confirmation text is typed', async () => {
render(<DeleteModal {...defaultProps} />);
const confirmationInput = await screen.findByPlaceholderText('Type Delete to confirm');
await userEvent.type(confirmationInput, 'Delete');
expect(await screen.findByRole('button', { name: 'Delete' })).toBeEnabled();
});
it('calls onConfirm when clicking the `Delete` button', async () => {
render(<DeleteModal {...defaultProps} />);
const confirmationInput = await screen.findByPlaceholderText('Type Delete to confirm');
await userEvent.type(confirmationInput, 'Delete');
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
expect(mockOnConfirm).toHaveBeenCalled();
});
it('calls onDismiss when clicking the `Cancel` button', async () => {
render(<DeleteModal {...defaultProps} />);
await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(mockOnDismiss).toHaveBeenCalled();
});
it('calls onDismiss when clicking the X', async () => {
render(<DeleteModal {...defaultProps} />);
await userEvent.click(await screen.findByRole('button', { name: 'Close dialog' }));
expect(mockOnDismiss).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,62 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, isTruthy } from '@grafana/data';
import { ConfirmModal, useStyles2 } from '@grafana/ui';
import { DashboardTreeSelection } from '../../types';
import { buildBreakdownString } from './utils';
export interface Props {
isOpen: boolean;
onConfirm: () => void;
onDismiss: () => void;
selectedItems: DashboardTreeSelection;
}
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const styles = useStyles2(getStyles);
// TODO abstract all this counting logic out
const folderCount = Object.values(selectedItems.folder).filter(isTruthy).length;
const dashboardCount = Object.values(selectedItems.dashboard).filter(isTruthy).length;
// hardcoded values for now
// TODO replace with dummy API
const libraryPanelCount = 1;
const alertRuleCount = 1;
const onDelete = () => {
onConfirm();
onDismiss();
};
return (
<ConfirmModal
body={
<div className={styles.modalBody}>
This action will delete the following content:
<p className={styles.breakdown}>
{buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)}
</p>
</div>
}
confirmationText="Delete"
confirmText="Delete"
onDismiss={onDismiss}
onConfirm={onDelete}
title="Delete Compute Resources"
{...props}
/>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
breakdown: css({
...theme.typography.bodySmall,
color: theme.colors.text.secondary,
}),
modalBody: css({
...theme.typography.body,
}),
});

View File

@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
import * as api from 'app/features/manage-dashboards/state/actions';
import { DashboardSearchHit } from 'app/features/search/types';
import { MoveModal, Props } from './MoveModal';
describe('browse-dashboards MoveModal', () => {
const mockOnDismiss = jest.fn();
const mockOnConfirm = jest.fn();
const mockFolders = [
{ title: 'General', uid: '' } as DashboardSearchHit,
{ title: 'Folder 1', uid: 'wfTJJL5Wz' } as DashboardSearchHit,
];
let props: Props;
beforeEach(() => {
props = {
isOpen: true,
onConfirm: mockOnConfirm,
onDismiss: mockOnDismiss,
selectedItems: {
folder: {},
dashboard: {},
panel: {},
},
};
// mock the searchFolders api call so the folder picker has some folders in it
jest.spyOn(api, 'searchFolders').mockResolvedValue(mockFolders);
});
it('renders a dialog with the correct title', async () => {
render(<MoveModal {...props} />);
expect(await screen.findByRole('dialog', { name: 'Move' })).toBeInTheDocument();
});
it('displays a `Move` button', async () => {
render(<MoveModal {...props} />);
expect(await screen.findByRole('button', { name: 'Move' })).toBeInTheDocument();
});
it('displays a `Cancel` button', async () => {
render(<MoveModal {...props} />);
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
it('displays a folder picker', async () => {
render(<MoveModal {...props} />);
expect(await screen.findByRole('combobox', { name: 'Select a folder' })).toBeInTheDocument();
});
it('displays a warning about permissions if a folder is selected', async () => {
props.selectedItems.folder = {
myFolderUid: true,
};
render(<MoveModal {...props} />);
expect(await screen.findByText('Moving this item may change its permissions.')).toBeInTheDocument();
});
it('only enables the `Move` button if a folder is selected', async () => {
render(<MoveModal {...props} />);
expect(await screen.findByRole('button', { name: 'Move' })).toBeDisabled();
const folderPicker = await screen.findByRole('combobox', { name: 'Select a folder' });
await selectOptionInTest(folderPicker, mockFolders[1].title);
expect(await screen.findByRole('button', { name: 'Move' })).toBeEnabled();
});
it('calls onConfirm when clicking the `Move` button', async () => {
render(<MoveModal {...props} />);
const folderPicker = await screen.findByRole('combobox', { name: 'Select a folder' });
await selectOptionInTest(folderPicker, mockFolders[1].title);
await userEvent.click(await screen.findByRole('button', { name: 'Move' }));
expect(mockOnConfirm).toHaveBeenCalledWith(mockFolders[1].uid);
});
it('calls onDismiss when clicking the `Cancel` button', async () => {
render(<MoveModal {...props} />);
await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(mockOnDismiss).toHaveBeenCalled();
});
it('calls onDismiss when clicking the X', async () => {
render(<MoveModal {...props} />);
await userEvent.click(await screen.findByRole('button', { name: 'Close dialog' }));
expect(mockOnDismiss).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,65 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { GrafanaTheme2, isTruthy } from '@grafana/data';
import { Alert, Button, Field, Modal, useStyles2 } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { DashboardTreeSelection } from '../../types';
import { buildBreakdownString } from './utils';
export interface Props {
isOpen: boolean;
onConfirm: (targetFolderUid: string) => void;
onDismiss: () => void;
selectedItems: DashboardTreeSelection;
}
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const [moveTarget, setMoveTarget] = useState<string>();
const styles = useStyles2(getStyles);
// TODO abstract all this counting logic out
const folderCount = Object.values(selectedItems.folder).filter(isTruthy).length;
const dashboardCount = Object.values(selectedItems.dashboard).filter(isTruthy).length;
// hardcoded values for now
// TODO replace with dummy API
const libraryPanelCount = 1;
const alertRuleCount = 1;
const onMove = () => {
if (moveTarget !== undefined) {
onConfirm(moveTarget);
}
onDismiss();
};
return (
<Modal title="Move" onDismiss={onDismiss} {...props}>
{folderCount > 0 && <Alert severity="warning" title="Moving this item may change its permissions." />}
This action will move the following content:
<p className={styles.breakdown}>
{buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)}
</p>
<Field label="Folder name">
<FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} />
</Field>
<Modal.ButtonRow>
<Button onClick={onDismiss} variant="secondary">
Cancel
</Button>
<Button disabled={moveTarget === undefined} onClick={onMove} variant="primary">
Move
</Button>
</Modal.ButtonRow>
</Modal>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
breakdown: css({
...theme.typography.bodySmall,
color: theme.colors.text.secondary,
}),
});

View File

@ -0,0 +1,23 @@
import { buildBreakdownString } from './utils';
describe('browse-dashboards utils', () => {
describe('buildBreakdownString', () => {
it.each`
folderCount | dashboardCount | libraryPanelCount | alertRuleCount | expected
${0} | ${0} | ${0} | ${0} | ${'0 items'}
${1} | ${0} | ${0} | ${0} | ${'1 item: 1 folder'}
${2} | ${0} | ${0} | ${0} | ${'2 items: 2 folders'}
${0} | ${1} | ${0} | ${0} | ${'1 item: 1 dashboard'}
${0} | ${2} | ${0} | ${0} | ${'2 items: 2 dashboards'}
${1} | ${0} | ${1} | ${1} | ${'3 items: 1 folder, 1 library panel, 1 alert rule'}
${2} | ${0} | ${3} | ${4} | ${'9 items: 2 folders, 3 library panels, 4 alert rules'}
${1} | ${1} | ${1} | ${1} | ${'4 items: 1 folder, 1 dashboard, 1 library panel, 1 alert rule'}
${1} | ${2} | ${3} | ${4} | ${'10 items: 1 folder, 2 dashboards, 3 library panels, 4 alert rules'}
`(
'returns the correct message for the various inputs',
({ folderCount, dashboardCount, libraryPanelCount, alertRuleCount, expected }) => {
expect(buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)).toEqual(expected);
}
);
});
});

View File

@ -0,0 +1,26 @@
export function buildBreakdownString(
folderCount: number,
dashboardCount: number,
libraryPanelCount: number,
alertRuleCount: number
) {
const total = folderCount + dashboardCount + libraryPanelCount + alertRuleCount;
const parts = [];
if (folderCount) {
parts.push(`${folderCount} ${folderCount === 1 ? 'folder' : 'folders'}`);
}
if (dashboardCount) {
parts.push(`${dashboardCount} ${dashboardCount === 1 ? 'dashboard' : 'dashboards'}`);
}
if (libraryPanelCount) {
parts.push(`${libraryPanelCount} ${libraryPanelCount === 1 ? 'library panel' : 'library panels'}`);
}
if (alertRuleCount) {
parts.push(`${alertRuleCount} ${alertRuleCount === 1 ? 'alert rule' : 'alert rules'}`);
}
let breakdownString = `${total} ${total === 1 ? 'item' : 'items'}`;
if (parts.length > 0) {
breakdownString += `: ${parts.join(', ')}`;
}
return breakdownString;
}

View File

@ -184,7 +184,7 @@ describe('FolderSettingsPage', () => {
await userEvent.click(deleteButton);
const deleteModal = screen.getByRole('dialog', { name: 'Delete' });
expect(deleteModal).toBeInTheDocument();
const deleteButtonModal = within(deleteModal).getByRole('button', { name: 'Confirm Modal Danger Button' });
const deleteButtonModal = within(deleteModal).getByRole('button', { name: 'Delete' });
await userEvent.click(deleteButtonModal);
expect(mockDeleteFolder).toHaveBeenCalledWith(mockFolder.uid);
});

View File

@ -1,4 +1,4 @@
import { render, screen, within } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { config } from 'app/core/config';
@ -14,8 +14,7 @@ describe('ConfirmModal', () => {
expect(screen.getByRole('heading', { name: 'Delete' })).toBeInTheDocument();
expect(screen.getByText('Do you want to delete the 2 selected dashboards?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
const button = screen.getByRole('button', { name: 'Confirm Modal Danger Button' });
expect(within(button).getByText('Delete')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Type delete to confirm')).not.toBeInTheDocument();
});

View File

@ -135,7 +135,7 @@ describe('ServiceAccountsListPage tests', () => {
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /Disable/ }));
await user.click(screen.getByLabelText(/Confirm Modal Danger Button/));
await user.click(screen.getByRole('button', { name: 'Disable service account' }));
expect(updateServiceAccountMock).toHaveBeenCalledWith({
...getDefaultServiceAccount(),
@ -152,7 +152,7 @@ describe('ServiceAccountsListPage tests', () => {
const user = userEvent.setup();
await user.click(screen.getByLabelText(/Delete service account/));
await user.click(screen.getByLabelText(/Confirm Modal Danger Button/));
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(deleteServiceAccountMock).toHaveBeenCalledWith(42);
});

View File

@ -68,7 +68,7 @@ describe('SelectedLogsGroups', () => {
await waitFor(() =>
expect(screen.getByText('Are you sure you want to clear all log groups?')).toBeInTheDocument()
);
await waitFor(() => userEvent.click(screen.getByLabelText('Confirm Modal Danger Button')));
await waitFor(() => userEvent.click(screen.getByRole('button', { name: 'Yes' })));
expect(defaultProps.onChange).toHaveBeenCalledWith([]);
});
});