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}
+
+
+ }
+ >
+
+
+ );
+}
+
+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;