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: ( +
+
+

+ AAAA +

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

    -
    -
    + + ) )}
    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 && ( -
    - - -
      - {children} -
    -
    -
    -
    - )} - - )} -
    -
    + + + + + +

    + {group} +

    + + } + > +
      + {children} +
    +
    ); } 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}'`, () => {