diff --git a/assets/js/components/ChecksSelection/ChecksSelection.jsx b/assets/js/components/ChecksSelection/ChecksSelection.jsx new file mode 100644 index 0000000000..86011fe1e8 --- /dev/null +++ b/assets/js/components/ChecksSelection/ChecksSelection.jsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect } from 'react'; +import classNames from 'classnames'; + +import { remove, uniq, toggle, groupBy } from '@lib/lists'; + +import { EOS_LOADING_ANIMATED } from 'eos-icons-react'; + +import CatalogContainer from '@components/ChecksCatalog/CatalogContainer'; +import ChecksSelectionGroup, { + NONE_CHECKED, + SOME_CHECKED, + ALL_CHECKED, + allSelected, +} from './ChecksSelectionGroup'; +import ChecksSelectionItem from './ChecksSelectionItem'; +import FailAlert from './FailAlert'; +import ExecutionSuggestion from './ExecutionSuggestion'; + +const isSelected = (selectedChecks, checkID) => + selectedChecks ? selectedChecks.includes(checkID) : false; + +const getGroupSelectedState = (checks, selectedChecks) => { + if (checks.every(({ id }) => isSelected(selectedChecks, id))) { + return ALL_CHECKED; + } + if (checks.some((check) => isSelected(selectedChecks, check.id))) { + return SOME_CHECKED; + } + return NONE_CHECKED; +}; + +const defaultSelectedChecks = []; + +function ChecksSelection({ + className, + targetID, + catalog, + selected = defaultSelectedChecks, + loading = false, + saving = false, + error, + success = false, + catalogError, + hosts, + onUpdateCatalog, + onStartExecution, + onSave, + onClear, +}) { + const [selectedChecks, setSelectedChecks] = useState(selected); + + const groupedChecks = Object.entries(groupBy(catalog, 'group')).map( + ([group, checks]) => { + const groupChecks = checks.map((check) => ({ + ...check, + selected: isSelected(selectedChecks, check.id), + })); + + return { + group, + checks: groupChecks, + groupSelected: getGroupSelectedState(checks, selectedChecks), + }; + } + ); + + useEffect(() => { + onUpdateCatalog(); + onClear(); + }, [onUpdateCatalog, onClear]); + + const onCheckSelectionGroupChange = (checks, groupSelected) => { + const groupChecks = checks.map((check) => check.id); + if (allSelected(groupSelected)) { + setSelectedChecks(remove(groupChecks, selectedChecks)); + } else { + setSelectedChecks(uniq([...selectedChecks, ...groupChecks])); + } + onClear(); + }; + + return ( +
+ +
+
+ {groupedChecks?.map(({ group, checks, groupSelected }) => ( + + onCheckSelectionGroupChange(checks, groupSelected) + } + > + {checks.map((check) => ( + { + setSelectedChecks(toggle(check.id, selectedChecks)); + onClear(); + }} + /> + ))} + + ))} +
+
+ + {error && ( + +

{error}

+
+ )} + {success && selectedChecks.length > 0 && ( + + )} +
+
+
+
+ ); +} + +export default ChecksSelection; diff --git a/assets/js/components/ChecksSelection/ChecksSelection.stories.jsx b/assets/js/components/ChecksSelection/ChecksSelection.stories.jsx new file mode 100644 index 0000000000..7d7d74457a --- /dev/null +++ b/assets/js/components/ChecksSelection/ChecksSelection.stories.jsx @@ -0,0 +1,128 @@ +import { faker } from '@faker-js/faker'; + +import { catalogCheckFactory } from '@lib/test-utils/factories'; + +import ChecksSelection from './ChecksSelection'; + +const catalog = [ + catalogCheckFactory.build({ group: 'Corosync' }), + catalogCheckFactory.build({ group: 'Corosync' }), + catalogCheckFactory.build({ group: 'Corosync' }), + catalogCheckFactory.build({ group: 'Corosync' }), + catalogCheckFactory.build({ group: 'Corosync' }), + catalogCheckFactory.build({ group: 'SBD' }), + catalogCheckFactory.build({ group: 'SBD' }), + catalogCheckFactory.build({ group: 'Miscellaneous' }), + catalogCheckFactory.build({ group: 'Miscellaneous' }), +]; + +const selectedChecks = [ + catalog[0].id, + catalog[1].id, + catalog[5].id, + catalog[6].id, +]; + +const targetID = faker.datatype.uuid(); + +export default { + title: 'ChecksSelection', + component: ChecksSelection, + argTypes: { + className: { + control: 'text', + description: 'CSS classes', + table: { + type: { summary: 'string' }, + }, + }, + catalog: { + control: 'object', + description: 'Catalog data', + table: { + type: { summary: 'object' }, + }, + }, + targetID: { + control: 'text', + description: 'Target ID', + table: { + type: { summary: 'string' }, + }, + }, + loading: { + control: { type: 'boolean' }, + description: 'Loading state', + table: { + type: { summary: 'string' }, + defaultValue: { summary: false }, + }, + }, + saving: { + control: { type: 'boolean' }, + description: 'Saving state', + table: { + type: { summary: 'string' }, + defaultValue: { summary: false }, + }, + }, + error: { + control: { type: 'string' }, + description: 'Saving error', + table: { + type: { summary: 'string' }, + }, + }, + success: { + control: { type: 'boolean' }, + description: 'Was the saving successful?', + table: { + type: { summary: 'string' }, + defaultValue: { summary: false }, + }, + }, + onUpdateCatalog: { action: 'Update catalog' }, + onStartExecution: { action: 'Start execution' }, + onSave: { action: 'Save' }, + onClear: { + action: 'Clear', + description: + 'Gets called on mount and when checks are selected. It can be used to clear any external state.', + }, + }, +}; + +export const Default = { + args: { + catalog, + targetID, + }, +}; + +export const Loading = { + args: { + ...Default.args, + loading: true, + }, +}; + +export const WithSelection = { + args: { + ...Default.args, + selected: selectedChecks, + }, +}; + +export const WithError = { + args: { + ...WithSelection.args, + error: 'Error saving checks selection', + }, +}; + +export const Saving = { + args: { + ...WithSelection.args, + saving: true, + }, +}; diff --git a/assets/js/components/ChecksSelection/ChecksSelection.test.jsx b/assets/js/components/ChecksSelection/ChecksSelection.test.jsx new file mode 100644 index 0000000000..6b94c78b5d --- /dev/null +++ b/assets/js/components/ChecksSelection/ChecksSelection.test.jsx @@ -0,0 +1,150 @@ +import React from 'react'; + +import { screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; + +import { faker } from '@faker-js/faker'; +import { renderWithRouter } from '@lib/test-utils'; +import { catalogCheckFactory } from '@lib/test-utils/factories'; + +import ChecksSelection from './ChecksSelection'; + +describe('ChecksSelection component', () => { + it('should change individual check switches accordingly if the group switch is clicked', async () => { + const user = userEvent.setup(); + + const group = faker.animal.cat(); + const catalog = catalogCheckFactory.buildList(2, { group }); + + const onUpdateCatalog = jest.fn(); + const onClear = jest.fn(); + + renderWithRouter( + + ); + + const groupItem = await waitFor(() => screen.getByText(group)); + + await user.click(groupItem); + + const switches = screen.getAllByRole('switch'); + + expect(switches[0]).not.toBeChecked(); + expect(switches[1]).not.toBeChecked(); + expect(switches[2]).not.toBeChecked(); + + await user.click(switches[0]); + + const selectedSwitches = screen.getAllByRole('switch'); + + expect(selectedSwitches[1]).toBeChecked(); + expect(selectedSwitches[2]).toBeChecked(); + + await user.click(switches[0]); + + const unselectedSwitches = screen.getAllByRole('switch'); + + expect(unselectedSwitches[1]).not.toBeChecked(); + expect(unselectedSwitches[2]).not.toBeChecked(); + expect(onUpdateCatalog).toBeCalled(); + expect(onClear).toBeCalled(); + }); + + it('should change group check switch accordingly if the children check switches are clicked', async () => { + const user = userEvent.setup(); + + const group = faker.animal.cat(); + const catalog = catalogCheckFactory.buildList(2, { group }); + const selectedChecks = [catalog[0].id, catalog[1].id]; + + const onUpdateCatalog = jest.fn(); + const onClear = jest.fn(); + + renderWithRouter( + + ); + + const groupItem = await waitFor(() => screen.getByText(group)); + + await user.click(groupItem); + + const switches = screen.getAllByRole('switch'); + + expect(switches[0]).toBeChecked(); + expect(switches[1]).toBeChecked(); + expect(switches[2]).toBeChecked(); + + await user.click(switches[1]); + + const offSwitches = screen.getAllByRole('switch'); + + expect(offSwitches[0]).not.toBeChecked(); + + await user.click(offSwitches[2]); + + expect(screen.getAllByRole('switch')[0]).not.toBeChecked(); + expect(onUpdateCatalog).toBeCalled(); + expect(onClear).toBeCalled(); + }); + + it('should display the error message if any', () => { + const error = faker.lorem.word(); + const catalog = catalogCheckFactory.buildList(10); + const onUpdateCatalog = jest.fn(); + const onClear = jest.fn(); + + renderWithRouter( + + ); + + expect(screen.getByText(error)).toBeVisible(); + expect(onUpdateCatalog).toBeCalled(); + expect(onClear).toBeCalled(); + }); + + it('should call the onSave callback when saving the modifications', async () => { + const onSave = jest.fn(); + const onUpdateCatalog = jest.fn(); + const onClear = jest.fn(); + const user = userEvent.setup(); + const targetID = faker.datatype.uuid(); + + const group = faker.animal.cat(); + const catalog = catalogCheckFactory.buildList(2, { group }); + const [{ id: checkID1 }, { id: checkID2 }] = catalog; + + renderWithRouter( + + ); + + const switches = screen.getAllByRole('switch'); + + await user.click(switches[0]); + await user.click(screen.getByText('Select Checks for Execution')); + + expect(onSave).toBeCalledWith([checkID1, checkID2], targetID); + expect(onUpdateCatalog).toBeCalled(); + expect(onClear).toBeCalled(); + }); +}); diff --git a/assets/js/components/ChecksSelection/ChecksSelectionGroup.jsx b/assets/js/components/ChecksSelection/ChecksSelectionGroup.jsx new file mode 100644 index 0000000000..42e277d3fa --- /dev/null +++ b/assets/js/components/ChecksSelection/ChecksSelectionGroup.jsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import classNames from 'classnames'; + +import { Switch } from '@headlessui/react'; + +import Accordion from '@components/Accordion'; + +export const NONE_CHECKED = 'none'; +export const SOME_CHECKED = 'some'; +export const ALL_CHECKED = 'all'; + +const switchClasses = { + [NONE_CHECKED]: 'bg-gray-200', + [SOME_CHECKED]: 'bg-green-300', + [ALL_CHECKED]: 'bg-jungle-green-500', +}; + +const translateClasses = { + [NONE_CHECKED]: 'translate-x-0', + [SOME_CHECKED]: 'translate-x-2.5', + [ALL_CHECKED]: 'translate-x-5', +}; + +export const allSelected = (selectedState) => selectedState === ALL_CHECKED; + +function ChecksSelectionGroup({ + children, + group, + selected = NONE_CHECKED, + onChange = () => {}, +}) { + return ( + + + + + +

+ {group} +

+ + } + > +
    + {children} +
+
+ ); +} + +export default ChecksSelectionGroup; diff --git a/assets/js/components/ChecksSelection/ChecksSelectionGroup.test.jsx b/assets/js/components/ChecksSelection/ChecksSelectionGroup.test.jsx new file mode 100644 index 0000000000..b5ca6d874d --- /dev/null +++ b/assets/js/components/ChecksSelection/ChecksSelectionGroup.test.jsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { render, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; + +import ChecksSelectionGroup, { + NONE_CHECKED, + SOME_CHECKED, + ALL_CHECKED, +} from './ChecksSelectionGroup'; + +describe('ClusterDetails ChecksSelectionGroup component', () => { + it('should show group with selected state', () => { + const group = 'some-group'; + + render(); + + expect(screen.getByText(group)).toBeVisible(); + expect(screen.getByRole('switch')).toBeChecked(); + expect( + screen.getByRole('switch').classList.contains('bg-jungle-green-500') + ).toBe(true); + }); + + it('should show group with some selected state', () => { + const group = 'some-group'; + + render(); + + expect(screen.getByText(group)).toBeVisible(); + expect(screen.getByRole('switch')).not.toBeChecked(); + expect(screen.getByRole('switch').classList.contains('bg-green-300')).toBe( + true + ); + }); + + it('should show group with none selected state', () => { + const group = 'some-group'; + + render(); + + expect(screen.getByText(group)).toBeVisible(); + expect(screen.getByRole('switch')).not.toBeChecked(); + expect(screen.getByRole('switch').classList.contains('bg-gray-200')).toBe( + true + ); + }); + + it('should show children checks when the group row is clicked', async () => { + const user = userEvent.setup(); + const group = 'some-group'; + + render( + + {[0, 1, 2].map((value) => ( +
  • {value}
  • + ))} +
    + ); + + await user.click(screen.getByRole('heading').parentNode); + const groupItem = screen.getAllByRole('list'); + expect(groupItem.length).toBe(1); + + const { getAllByRole } = within(groupItem[0]); + const checkItems = getAllByRole('listitem'); + expect(checkItems.length).toBe(3); + }); + + it('should run the onChange function when the switch button is clicked', async () => { + const group = 'some-group'; + const onChangeMock = jest.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('switch')); + expect(onChangeMock).toBeCalled(); + }); +}); diff --git a/assets/js/components/ChecksSelection/ChecksSelectionItem.jsx b/assets/js/components/ChecksSelection/ChecksSelectionItem.jsx new file mode 100644 index 0000000000..efc98b5d4a --- /dev/null +++ b/assets/js/components/ChecksSelection/ChecksSelectionItem.jsx @@ -0,0 +1,62 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import React from 'react'; + +import { Switch } from '@headlessui/react'; + +import classNames from 'classnames'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import PremiumPill from '@components/PremiumPill'; + +function ChecksSelectionItem({ + checkID, + name, + description, + premium = false, + selected, + onChange = () => {}, +}) { + return ( +
  • + +
    +
    +

    {name}

    +

    + {checkID} +

    + {premium && } +
    +
    +
    + + {description} + +
    + + + + +
    +
    +
    +
  • + ); +} + +export default ChecksSelectionItem; diff --git a/assets/js/components/ChecksSelection/ChecksSelectionItem.test.jsx b/assets/js/components/ChecksSelection/ChecksSelectionItem.test.jsx new file mode 100644 index 0000000000..b7974312ff --- /dev/null +++ b/assets/js/components/ChecksSelection/ChecksSelectionItem.test.jsx @@ -0,0 +1,83 @@ +import React from 'react'; + +import { screen, render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; + +import { catalogCheckFactory } from '@lib/test-utils/factories'; + +import ChecksSelectionItem from './ChecksSelectionItem'; + +describe('ClusterDetails ChecksSelectionItem component', () => { + it('should show check with selected state', () => { + const check = catalogCheckFactory.build(); + + render( + + ); + + expect(screen.getByText(check.id)).toBeVisible(); + expect(screen.getByText(check.name)).toBeVisible(); + expect(screen.getByText(check.description)).toBeVisible(); + expect(screen.getByRole('switch')).toBeChecked(); + }); + + it('should show check with unselected state', () => { + const check = catalogCheckFactory.build(); + + render( + + ); + + expect(screen.getByRole('switch')).not.toBeChecked(); + }); + + it('should run the onChange function when the switch button is clicked', async () => { + const user = userEvent.setup(); + const check = catalogCheckFactory.build(); + const onChangeMock = jest.fn(); + + render( + + ); + + await user.click(screen.getByRole('switch')); + expect(onChangeMock).toBeCalled(); + }); + + it('should show premium badge if the check is premium', () => { + const check = catalogCheckFactory.build(); + + render( + + ); + + expect(screen.getByText('Premium')).toBeVisible(); + }); +}); diff --git a/assets/js/components/ChecksSelection/ExecutionSuggestion.jsx b/assets/js/components/ChecksSelection/ExecutionSuggestion.jsx new file mode 100644 index 0000000000..438d72cecd --- /dev/null +++ b/assets/js/components/ChecksSelection/ExecutionSuggestion.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { EOS_CANCEL, EOS_PLAY_CIRCLE } from 'eos-icons-react'; + +import TriggerChecksExecutionRequest from '@components/TriggerChecksExecutionRequest'; + +function ExecutionSuggestion({ + targetID, + selectedChecks, + hosts, + onClose = () => {}, + onStartExecution = () => {}, +}) { + return ( +
    +
    +

    + Well done! To start execution now, click here 👉{' '} +

    + + + + +
    +
    + ); +} + +export default ExecutionSuggestion; diff --git a/assets/js/components/ChecksSelection/FailAlert.jsx b/assets/js/components/ChecksSelection/FailAlert.jsx new file mode 100644 index 0000000000..daa2663a22 --- /dev/null +++ b/assets/js/components/ChecksSelection/FailAlert.jsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { EOS_CANCEL } from 'eos-icons-react'; + +function FailAlert({ onClose = () => {}, children }) { + return ( +
    + {children} + +
    + ); +} + +export default FailAlert; diff --git a/assets/js/components/ChecksSelection/index.js b/assets/js/components/ChecksSelection/index.js new file mode 100644 index 0000000000..0cf7bbdd75 --- /dev/null +++ b/assets/js/components/ChecksSelection/index.js @@ -0,0 +1,3 @@ +import ChecksSelection from './ChecksSelection'; + +export default ChecksSelection;