Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Collapsible checks catalog #1369

Merged
merged 6 commits into from
Apr 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions assets/js/components/Accordion/Accordion.jsx
Original file line number Diff line number Diff line change
@@ -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 = (
<Disclosure.Panel aria-label="accordion-panel">{children}</Disclosure.Panel>
);
const isHeaderAnElement = isValidElement(header);
return (
<Disclosure
as="div"
defaultOpen={defaultOpen}
className={classNames(
'bg-white shadow overflow-hidden',
{ 'rounded-md': rounded },
className
)}
>
{({ open }) => (
<>
<Disclosure.Button
aria-label="accordion-header"
as="div"
className={classNames(
'cursor-pointer flex w-full justify-between border-b border-gray-200',
headerClassnames
)}
>
{isHeaderAnElement ? (
header
) : (
<div className="bg-white p-6 sm:px-6">
<h3 className="text-lg leading-6 font-semibold text-gray-900">
{header}
</h3>
</div>
)}

{withHandle && (
<div className="flex p-6" aria-label="accordion-handle">
<EOS_KEYBOARD_ARROW_DOWN
className={classNames('self-center fill-gray-500', {
'transform rotate-180': open,
})}
/>
</div>
)}
</Disclosure.Button>
{withTransition ? (
<Transition
aria-label="accordion-transition-panel"
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
{disclosurePanel}
</Transition>
) : (
disclosurePanel
)}
</>
)}
</Disclosure>
);
}

export default Accordion;
92 changes: 92 additions & 0 deletions assets/js/components/Accordion/Accordion.stories.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React from 'react';

import PremiumPill from '@components/PremiumPill';
import Accordion from '.';

export default {
title: 'Accordion',
component: Accordion,
nelsonkopliku marked this conversation as resolved.
Show resolved Hide resolved
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: <div className="p-6">Accordion content</div>,
},
};

export const WithoutHandle = {
args: {
...Default.args,
withHandle: false,
},
};

export const WithCustomHeader = {
args: {
...WithoutHandle.args,
header: (
<div className="check-row px-4 py-4 sm:px-6">
<div className="flex items-center">
<p className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
AAAA
</p>
<PremiumPill className="ml-1" />
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">Accordion with custom header</div>
</div>
</div>
),
},
};

export const WithTransition = {
args: {
...Default.args,
withTransition: true,
},
};
90 changes: 90 additions & 0 deletions assets/js/components/Accordion/Accordion.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<Accordion header={accordionHeader}>
<div>{accordionContent}</div>
</Accordion>
);

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(
<Accordion
header={<div data-testid="custom-header">{accordionHeader}</div>}
>
<div>{faker.lorem.paragraph()}</div>
</Accordion>
);

expect(screen.getByTestId('custom-header')).toBeVisible();
expect(screen.getByLabelText('accordion-header')).toHaveTextContent(
accordionHeader
);
});

it('should render an Accordion without a handle', () => {
render(
<Accordion header={faker.lorem.sentence()} withHandle={false}>
<div>{faker.lorem.paragraph()}</div>
</Accordion>
);

expect(screen.queryByLabelText('accordion-handle')).not.toBeInTheDocument();
});

it('should render an Accordion with a transitioning panel', async () => {
const accordionContent = faker.lorem.paragraph();

render(
<Accordion header={faker.lorem.sentence()} withTransition>
<div>{accordionContent}</div>
</Accordion>
);

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
);
});
});
});
3 changes: 3 additions & 0 deletions assets/js/components/Accordion/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Accordion from './Accordion';

export default Accordion;
44 changes: 17 additions & 27 deletions assets/js/components/ChecksCatalog/CheckItem.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<li>
<Disclosure>
<Disclosure.Button
as="div"
className="flex justify-between w-full cursor-pointer hover:bg-gray-100"
>
<Accordion
withHandle={false}
withTransition
rounded={false}
headerClassnames="hover:bg-gray-100"
header={
<div className="check-row px-4 py-4 sm:px-6">
<div className="flex items-center">
<p className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Expand All @@ -33,26 +33,16 @@ function CheckItem({ checkID, premium = false, description, remediation }) {
</div>
</div>
</div>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel className="check-panel border-none">
<div className="px-8 py-4 sm:px-8">
<div className="px-4 py-4 sm:px-4 bg-slate-100 rounded">
<ReactMarkdown className="markdown" remarkPlugins={[remarkGfm]}>
{remediation}
</ReactMarkdown>
</div>
</div>
</Disclosure.Panel>
</Transition>
</Disclosure>
}
>
<div className="check-panel px-8 py-4 sm:px-8 border-none">
<div className="px-4 py-4 sm:px-4 bg-slate-100 rounded">
<ReactMarkdown className="markdown" remarkPlugins={[remarkGfm]}>
{remediation}
</ReactMarkdown>
</div>
</div>
</Accordion>
</li>
);
}
Expand Down
3 changes: 1 addition & 2 deletions assets/js/components/ChecksCatalog/CheckItem.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 9 additions & 12 deletions assets/js/components/ChecksCatalog/ChecksCatalog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,16 +62,12 @@ function ChecksCatalog() {
<div>
{Object.entries(groupBy(catalogData, 'group')).map(
([group, checks], idx) => (
<div
key={idx}
className="check-group bg-white shadow overflow-hidden sm:rounded-md mb-8"
>
<div className="bg-white px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{group}
</h3>
</div>
<ul className="divide-y divide-gray-200">
<ul key={idx}>
<Accordion
defaultOpen
className="check-group mb-4"
header={group}
>
{checks.map((check) => (
<CheckItem
key={check.id}
Expand All @@ -80,8 +77,8 @@ function ChecksCatalog() {
remediation={check.remediation}
/>
))}
</ul>
</div>
</Accordion>
</ul>
)
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion assets/js/components/ChecksCatalog/ProviderSelection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function ProviderSelection({ className, providers, selected, onChange }) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
<Listbox.Options className="absolute w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm z-[1]">
{providers.map((provider, providerIdx) => (
<Listbox.Option
key={/* eslint-disable */ providerIdx}
Expand Down
Loading