diff --git a/assets/js/components/Accordion/Accordion.jsx b/assets/js/components/Accordion/Accordion.jsx
new file mode 100644
index 0000000000..c88637d620
--- /dev/null
+++ b/assets/js/components/Accordion/Accordion.jsx
@@ -0,0 +1,83 @@
+import React, { isValidElement } from 'react';
+
+import { Disclosure, Transition } from '@headlessui/react';
+import { EOS_KEYBOARD_ARROW_DOWN } from 'eos-icons-react';
+
+import classNames from 'classnames';
+
+function Accordion({
+ className,
+ header,
+ headerClassnames,
+ withHandle = true,
+ withTransition = false,
+ rounded = true,
+ defaultOpen = false,
+ children,
+}) {
+ const disclosurePanel = (
+ {children}
+ );
+ const isHeaderAnElement = isValidElement(header);
+ return (
+
+ {({ open }) => (
+ <>
+
+ {isHeaderAnElement ? (
+ header
+ ) : (
+
+
+ {header}
+
+
+ )}
+
+ {withHandle && (
+
+
+
+ )}
+
+ {withTransition ? (
+
+ {disclosurePanel}
+
+ ) : (
+ disclosurePanel
+ )}
+ >
+ )}
+
+ );
+}
+
+export default Accordion;
diff --git a/assets/js/components/Accordion/Accordion.stories.jsx b/assets/js/components/Accordion/Accordion.stories.jsx
new file mode 100644
index 0000000000..229d795cae
--- /dev/null
+++ b/assets/js/components/Accordion/Accordion.stories.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+
+import PremiumPill from '@components/PremiumPill';
+import Accordion from '.';
+
+export default {
+ title: 'Accordion',
+ component: Accordion,
+ argTypes: {
+ header: {
+ type: 'string',
+ description:
+ 'The content of the accordion header. It can be a plain string or a Component',
+ control: {
+ type: 'text',
+ },
+ },
+ children: {
+ description:
+ 'The content of the accordion panel. It should be a Component',
+ control: {
+ type: 'text',
+ },
+ },
+ withHandle: {
+ description:
+ 'Whether the accordion header should have a chevron icon handle or not',
+ control: {
+ type: 'boolean',
+ },
+ },
+ withTransition: {
+ description:
+ 'Whether the accordion panel should open/close with a transition animation',
+ control: {
+ type: 'boolean',
+ },
+ },
+ defaultOpen: {
+ description: 'Whether the accordion should render open by default',
+ control: {
+ type: 'boolean',
+ },
+ },
+ rounded: {
+ description: 'Whether the accordion container should be rounded or not',
+ control: {
+ type: 'boolean',
+ },
+ },
+ },
+};
+
+export const Default = {
+ args: {
+ header: 'Accordion Header',
+ children: Accordion content
,
+ },
+};
+
+export const WithoutHandle = {
+ args: {
+ ...Default.args,
+ withHandle: false,
+ },
+};
+
+export const WithCustomHeader = {
+ args: {
+ ...WithoutHandle.args,
+ header: (
+
+
+
+
Accordion with custom header
+
+
+ ),
+ },
+};
+
+export const WithTransition = {
+ args: {
+ ...Default.args,
+ withTransition: true,
+ },
+};
diff --git a/assets/js/components/Accordion/Accordion.test.jsx b/assets/js/components/Accordion/Accordion.test.jsx
new file mode 100644
index 0000000000..03c1cd2a3a
--- /dev/null
+++ b/assets/js/components/Accordion/Accordion.test.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+import { faker } from '@faker-js/faker';
+import Accordion from './Accordion';
+
+describe('Accordion Component', () => {
+ it('should render an Accordion', () => {
+ const accordionHeader = faker.lorem.sentence();
+ const accordionContent = faker.lorem.paragraph();
+
+ render(
+
+ {accordionContent}
+
+ );
+
+ const header = screen.getByLabelText('accordion-header');
+
+ expect(screen.getByLabelText('accordion-handle')).toBeVisible();
+ expect(header).toHaveTextContent(accordionHeader);
+ expect(screen.queryByLabelText('accordion-panel')).not.toBeInTheDocument();
+
+ expect(
+ screen.queryByLabelText('accordion-transition-panel')
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(header);
+ expect(screen.getByLabelText('accordion-panel')).toHaveTextContent(
+ accordionContent
+ );
+ expect(
+ screen.queryByLabelText('accordion-transition-panel')
+ ).not.toBeInTheDocument();
+ });
+
+ it('should render an Accordion with a custom header', () => {
+ const accordionHeader = faker.lorem.sentence();
+
+ render(
+ {accordionHeader}}
+ >
+ {faker.lorem.paragraph()}
+
+ );
+
+ expect(screen.getByTestId('custom-header')).toBeVisible();
+ expect(screen.getByLabelText('accordion-header')).toHaveTextContent(
+ accordionHeader
+ );
+ });
+
+ it('should render an Accordion without a handle', () => {
+ render(
+
+ {faker.lorem.paragraph()}
+
+ );
+
+ expect(screen.queryByLabelText('accordion-handle')).not.toBeInTheDocument();
+ });
+
+ it('should render an Accordion with a transitioning panel', async () => {
+ const accordionContent = faker.lorem.paragraph();
+
+ render(
+
+ {accordionContent}
+
+ );
+
+ expect(
+ screen.queryByLabelText('accordion-transition-panel')
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText('accordion-header'));
+ await waitFor(() => {
+ const transitionPanel = screen.getByLabelText(
+ 'accordion-transition-panel'
+ );
+ expect(transitionPanel).toBeVisible();
+ expect(screen.getByLabelText('accordion-panel')).toHaveTextContent(
+ accordionContent
+ );
+ });
+ });
+});
diff --git a/assets/js/components/Accordion/index.js b/assets/js/components/Accordion/index.js
new file mode 100644
index 0000000000..29f531dd87
--- /dev/null
+++ b/assets/js/components/Accordion/index.js
@@ -0,0 +1,3 @@
+import Accordion from './Accordion';
+
+export default Accordion;
diff --git a/assets/js/components/ChecksCatalog/CheckItem.jsx b/assets/js/components/ChecksCatalog/CheckItem.jsx
index ec2009ec75..8ccfa79bfe 100644
--- a/assets/js/components/ChecksCatalog/CheckItem.jsx
+++ b/assets/js/components/ChecksCatalog/CheckItem.jsx
@@ -1,20 +1,20 @@
import React from 'react';
-import { Disclosure, Transition } from '@headlessui/react';
-
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import PremiumPill from '@components/PremiumPill';
+import Accordion from '@components/Accordion';
function CheckItem({ checkID, premium = false, description, remediation }) {
return (
-
-
+
@@ -33,26 +33,16 @@ function CheckItem({ checkID, premium = false, description, remediation }) {
-
-
-
-
-
-
- {remediation}
-
-
-
-
-
-
+ }
+ >
+
+
+
+ {remediation}
+
+
+
+
);
}
diff --git a/assets/js/components/ChecksCatalog/CheckItem.test.jsx b/assets/js/components/ChecksCatalog/CheckItem.test.jsx
index d359b2a662..fdc03907af 100644
--- a/assets/js/components/ChecksCatalog/CheckItem.test.jsx
+++ b/assets/js/components/ChecksCatalog/CheckItem.test.jsx
@@ -55,8 +55,7 @@ describe('ChecksCatalog CheckItem component', () => {
/>
);
- const checks = screen.getAllByRole('listitem');
- const checkDiv = checks[0].querySelector('div');
+ const checkDiv = screen.getByText(check.id);
expect(screen.queryByText(check.remediation)).not.toBeInTheDocument();
await user.click(checkDiv);
diff --git a/assets/js/components/ChecksCatalog/ChecksCatalog.jsx b/assets/js/components/ChecksCatalog/ChecksCatalog.jsx
index 1e89447926..5c64a7d20e 100644
--- a/assets/js/components/ChecksCatalog/ChecksCatalog.jsx
+++ b/assets/js/components/ChecksCatalog/ChecksCatalog.jsx
@@ -11,6 +11,7 @@ import {
checkProviderExists,
} from '@components/ProviderLabel/ProviderLabel';
import PageHeader from '@components/PageHeader';
+import Accordion from '@components/Accordion';
import CatalogContainer from './CatalogContainer';
import CheckItem from './CheckItem';
import ProviderSelection from './ProviderSelection';
@@ -61,16 +62,12 @@ function ChecksCatalog() {
{Object.entries(groupBy(catalogData, 'group')).map(
([group, checks], idx) => (
-
-
-
- {group}
-
-
-
+
+
{checks.map((check) => (
))}
-
-
+
+
)
)}
diff --git a/assets/js/components/ChecksCatalog/ProviderSelection.jsx b/assets/js/components/ChecksCatalog/ProviderSelection.jsx
index 70e768b660..c4a8e7330d 100644
--- a/assets/js/components/ChecksCatalog/ProviderSelection.jsx
+++ b/assets/js/components/ChecksCatalog/ProviderSelection.jsx
@@ -36,7 +36,7 @@ function ProviderSelection({ className, providers, selected, onChange }) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
-
+
{providers.map((provider, providerIdx) => (
{},
}) {
return (
-
-
- {({ open }) => (
- <>
-
-
-
-
-
-
-
-
- {group}
-
-
-
-
-
-
- {open && (
-
- )}
- >
- )}
-
-
+
+
+
+
+
+
+
+ {group}
+
+
+ }
+ >
+
+
);
}
diff --git a/test/e2e/cypress/e2e/checks_catalog.cy.js b/test/e2e/cypress/e2e/checks_catalog.cy.js
index 9ad9e2214b..1eb5ae0afb 100644
--- a/test/e2e/cypress/e2e/checks_catalog.cy.js
+++ b/test/e2e/cypress/e2e/checks_catalog.cy.js
@@ -29,7 +29,7 @@ context('Checks catalog', () => {
describe('Checks grouping and identification is correct', () => {
Object.entries(groupBy(catalog, 'group')).forEach(([group, checks]) => {
it(`should include group '${group}'`, () => {
- cy.get('.check-group > div > h3').should('contain', group);
+ cy.get('.check-group > div > div > h3').should('contain', group);
});
checks.forEach(({ id }) => {
it(`should include check '${id}'`, () => {