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

feat(filter): L3-4114 - create dumb filter component #418

Merged
merged 12 commits into from
Nov 22, 2024
Merged
2 changes: 2 additions & 0 deletions src/componentStyles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@
@use 'components/PinchZoom/pinchZoom';
@use 'components/Tabs/tabs';
@use 'components/SeldonImage/seldonImage';
@use 'patterns/FilterControl/filterControl';
@use 'components/Filter/filter';
71 changes: 71 additions & 0 deletions src/components/Filter/Filter.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Meta } from '@storybook/react';
import Filter from './Filter';
import FilterHeader from './FilterHeader';
import FilterValue from './FilterValue';

// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta = {
title: 'Components/Filter',
component: Filter,
} satisfies Meta<typeof Filter>;

export default meta;

type FilterDimension = { label: string; disabled?: boolean | undefined };

type FilterType = {
label: string;
filterDimensions: FilterDimension[];
};

type PropTypes = {
filter: FilterType;
viewAllLimit: number;
isLast: boolean;
isViewingAll: boolean;
};

const filter: FilterType = {
label: 'Artists & Makers',
filterDimensions: [
{ label: 'Jimmy' },
{ label: 'Bob' },
{ label: 'Alan' },
{ label: 'Nick' },
{ label: 'Joe' },
{ label: 'Fred' },
{ label: 'Rob' },
{ label: 'Roy' },
{ label: 'disabled', disabled: true },
],
};

export const Playground = (props: PropTypes) => {
const { filter, viewAllLimit, isLast, isViewingAll } = props;
return (
<Filter key={filter.label} isLast={isLast} viewAllLimit={viewAllLimit} viewingAll={isViewingAll}>
<FilterHeader label={filter.label} />
{filter.filterDimensions.map((value: FilterDimension) => (
<FilterValue
key={value.label}
label={value.label}
onChange={(e) => {
e;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure why we need this e here

}}
inputType="checkbox"
disabled={value?.disabled}
/>
))}
</Filter>
);
};

// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
Playground.args = {
filter,
viewAllLimit: 10,
isLast: true,
isViewingAll: false,
};

Playground.argTypes = {};
53 changes: 53 additions & 0 deletions src/components/Filter/Filter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Filter from './Filter';
import { runCommonTests } from '../../utils/testUtils';
import { render, screen } from '@testing-library/react';
import FilterHeader from './FilterHeader';
import FilterValue from './FilterValue';

describe('Filter', () => {
runCommonTests(Filter, 'Filter');

it('renders the different input types of filter values', () => {
const handleChange = vi.fn();

render(
<Filter>
<FilterValue label="Filter 1" inputType="checkbox" onChange={handleChange} />
<FilterValue label="Filter 2" inputType="radio" onChange={handleChange} />
</Filter>,
);

expect(screen.getByText('Filter 1')).toBeInTheDocument();
expect(screen.getByText('Filter 2')).toBeInTheDocument();

const checkbox = screen.getByRole('checkbox');
const radio = screen.getByRole('radio');

expect(checkbox).toHaveAttribute('type', 'checkbox');
expect(radio).toHaveAttribute('type', 'radio');
});

it('renders a filter header', () => {
render(
<Filter>
<FilterHeader label="Filter Header 1" />
</Filter>,
);

expect(screen.getByText('Filter Header 1')).toBeInTheDocument();
});

it('should disable filters when disabled prop is passed', () => {
const handleChange = vi.fn();

render(
<Filter>
<FilterValue label="Filter 1" inputType="checkbox" onChange={handleChange} disabled />
</Filter>,
);

const checkbox = screen.getByRole('checkbox');
expect(screen.getByText('Filter 1')).toHaveClass('disabled-label');
expect(checkbox).toBeDisabled();
});
});
85 changes: 85 additions & 0 deletions src/components/Filter/Filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
ComponentProps,
forwardRef,
Children,
cloneElement,
isValidElement,
ReactNode,
Dispatch,
SetStateAction,
} from 'react';
import { getCommonProps, px } from '../../utils';
import classnames from 'classnames';
import FilterHeader, { FilterHeaderProps } from './FilterHeader';
import { FilterValueProps } from './FilterValue';
import Button from '../Button/Button';
import { ButtonVariants } from '../Button/types';
import ChevronNextIcon from '../../assets/chevronNext.svg?react';

// You'll need to change the ComponentProps<"htmlelementname"> to match the top-level element of your component
export interface FilterProps extends ComponentProps<'div'> {
children: ReactNode;

// If true, do not include bottom border. True by default, do not define if you do not wish to use separators.
isLast?: boolean;

// Setter for values to display in view all
setViewAllFilter?: Dispatch<SetStateAction<ReactNode[]>>;

// Number of values to display before truncating with view all button
viewAllLimit?: number;

// Whether this is a view all filter or not
viewingAll?: boolean;
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
}
/**
* ## Overview
*
* Component to display filtering dimensions
*
* [Figma Link](https://www.figma.com/design/OvBXAq48blO1r4qYbeBPjW/RW---Sale-Page-(PLP)?node-id=892-71019&node-type=frame&t=AsBDn4UgUEjNUnf7-0)
*
* [Storybook Link](https://phillips-seldon.netlify.app/?path=/docs/components-filter--overview)
*/
const Filter = forwardRef<HTMLDivElement, FilterProps>(
({ className, children, isLast = true, viewAllLimit = 10, viewingAll = false, setViewAllFilter, ...props }, ref) => {
const { className: baseClassName, ...commonProps } = getCommonProps(props, 'Filter');
const childrenArray = Children.toArray(children);

return (
<div
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
{...commonProps}
className={classnames(baseClassName, className, {
[`${px}-has-separators`]: !isLast,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really clear, why do we need this separator?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay I see now, maybe we should Separator component for that, @scottdickerson wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When there are multiple filters, the design shows there is a separator between them. This Filter component, for the majority of cases I would think, would be implemented alongside other Filters. I created the FilterControl component to wrap and manage these.

})}
{...props}
ref={ref}
>
{childrenArray.map((child, index) =>
isValidElement(child)
? child.type === FilterHeader
? cloneElement(child, { showBack: viewingAll, setViewAllFilter } as FilterHeaderProps)
: cloneElement(child, { isHidden: !viewingAll && index > viewAllLimit } as FilterValueProps)
: child,
)}
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
{childrenArray.length > viewAllLimit && !viewingAll ? (
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
<Button
variant={ButtonVariants.tertiary}
onClick={() => {
setViewAllFilter && setViewAllFilter(childrenArray);
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
}}
>
{`View All`}
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
<ChevronNextIcon className={`${baseClassName}__chevron`} />
</Button>
) : null}
</div>
);
},
);

Filter.displayName = 'Filter';

export type FilterComponent = ReturnType<typeof Filter>;

export default Filter;
51 changes: 51 additions & 0 deletions src/components/Filter/FilterHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ComponentProps, Dispatch, SetStateAction, forwardRef } from 'react';
import { getCommonProps } from '../../utils';
import classnames from 'classnames';
import { Text, TextVariants } from '../Text';
import Button from '../Button/Button';
import { ButtonVariants } from '../Button/types';
import ChevronNextIcon from '../../assets/chevronNext.svg?react';

export interface FilterHeaderProps extends ComponentProps<'div'> {
// Text to display as the header
label: string;

// Whether the show all back button should be displayed (when viewing all filters)
showBack?: boolean;

// Setter function for info to be displayed in View All
setViewAllFilter?: Dispatch<SetStateAction<[]>>;
}
/**
* ## Overview
*
* The header of a filter
*/
const FilterHeader = forwardRef<HTMLDivElement, FilterHeaderProps>(
({ className, label, showBack = false, setViewAllFilter, ...props }, ref) => {
const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterHeader');

return (
<div {...commonProps} className={classnames(baseClassName, className)} {...props} ref={ref}>
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
<Text variant={TextVariants.heading4} className={`${baseClassName}__label`}>
{label}
</Text>
{showBack ? (
<Button
variant={ButtonVariants.tertiary}
onClick={() => {
setViewAllFilter && setViewAllFilter([]);
}}
>
<ChevronNextIcon className={`${baseClassName}__chevron`} />
{`Back to all`}
</Button>
) : null}
</div>
);
},
);

FilterHeader.displayName = 'FilterHeader';

export default FilterHeader;
59 changes: 59 additions & 0 deletions src/components/Filter/FilterValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ComponentProps, forwardRef } from 'react';
import { getCommonProps } from '../../utils';
import classnames from 'classnames';
import { Text, TextVariants } from '../Text';
import Input from '../Input/Input';

// You'll need to change the ComponentProps<"htmlelementname"> to match the top-level element of your component
export interface FilterValueProps extends Omit<ComponentProps<'div'>, 'onChange'> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an FYI this would be a good use of a Type Generic where the ValueType is passed and used in a value prop. So consumers would say <FilterValue<boolean>> for instance and our prop model would say value: ValueType. We're not doing it in the <Input components either so this is kinda tech debt across seldon

// Text to be displayed as a label for the neighboring input
label: string;

// Type of input for this filter
inputType: 'checkbox' | 'radio';

// Should this filter be hidden, most common use will be for view all truncation
isHidden?: boolean;

// Whether or not this filter is disabled
disabled?: boolean;

// Function to trigger when the state of this filter is changed
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
/**
* ## Overview
*
* A label & input for filtering criteria
*
*/
const FilterValue = forwardRef<HTMLDivElement, FilterValueProps>(
({ className, label, inputType, isHidden = false, onChange, disabled, ...props }, ref) => {
const { className: baseClassName, ...commonProps } = getCommonProps(props, 'FilterValue');
const disabledLabel = disabled ? 'disabled-label' : '';
return (
<>
{isHidden ? null : (
<div {...commonProps} className={classnames(baseClassName, className)} {...props} ref={ref}>
<Text variant={TextVariants.body2} className={`${baseClassName}__label ${disabledLabel}`}>
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
{label}
</Text>
<Input
disabled={disabled}
type={inputType}
className={`${baseClassName}__input`}
onChange={onChange}
hideLabel={true}
size="lg"
value={label}
Zach-OfAllTrades marked this conversation as resolved.
Show resolved Hide resolved
/>
</div>
)}
</>
);
},
);

FilterValue.displayName = 'FilterValue';

export default FilterValue;
Loading
Loading