From 2ebad62b1c42e7cc9eb9cdaa39f5c2ab943b0994 Mon Sep 17 00:00:00 2001 From: Emanuele De Cupis Date: Wed, 7 Aug 2024 15:55:07 +0200 Subject: [PATCH] Add `` component (#2849) --- .../common/ComposedFilter/ComposedFilter.jsx | 24 +- .../ComposedFilter/ComposedFilter.stories.jsx | 21 ++ assets/js/common/DateFilter/DateFilter.jsx | 217 ++++++++++++++++++ .../common/DateFilter/DateFilter.stories.jsx | 100 ++++++++ .../js/common/DateFilter/DateFilter.test.jsx | 127 ++++++++++ assets/js/common/DateFilter/index.js | 3 + 6 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 assets/js/common/DateFilter/DateFilter.jsx create mode 100644 assets/js/common/DateFilter/DateFilter.stories.jsx create mode 100644 assets/js/common/DateFilter/DateFilter.test.jsx create mode 100644 assets/js/common/DateFilter/index.js diff --git a/assets/js/common/ComposedFilter/ComposedFilter.jsx b/assets/js/common/ComposedFilter/ComposedFilter.jsx index 6e40398e1c..e45126041a 100644 --- a/assets/js/common/ComposedFilter/ComposedFilter.jsx +++ b/assets/js/common/ComposedFilter/ComposedFilter.jsx @@ -1,11 +1,27 @@ import React, { useState } from 'react'; import Button from '@common/Button'; import Filter from '@common/Filter'; +import DateFilter from '@common/DateFilter'; -const renderFilter = (key, { type, ...filterProps }, value, onChange) => - type === 'select' ? ( - - ) : null; +const renderFilter = (key, { type, ...filterProps }, value, onChange) => { + switch (type) { + case 'select': + return ( + + ); + case 'date': + return ( + + ); + default: + return null; + } +}; /** * Define a filter which is the composition of several filters. diff --git a/assets/js/common/ComposedFilter/ComposedFilter.stories.jsx b/assets/js/common/ComposedFilter/ComposedFilter.stories.jsx index 9523c538db..8376122485 100644 --- a/assets/js/common/ComposedFilter/ComposedFilter.stories.jsx +++ b/assets/js/common/ComposedFilter/ComposedFilter.stories.jsx @@ -68,3 +68,24 @@ export const Default = { onChange: action('onChange'), }, }; + +export const WithDateFilter = { + args: { + filters: [ + { + key: 'filter1', + type: 'select', + title: 'Pasta', + options: ['Carbonara', 'Amatriciana', 'Ajo & Ojo', 'Gricia'], + }, + { + key: 'filter2', + type: 'date', + title: 'Date', + prefilled: true, + options: [['My birthday', () => new Date(1986, 0, 24)]], + }, + ], + onChange: action('onChange'), + }, +}; diff --git a/assets/js/common/DateFilter/DateFilter.jsx b/assets/js/common/DateFilter/DateFilter.jsx new file mode 100644 index 0000000000..033777d705 --- /dev/null +++ b/assets/js/common/DateFilter/DateFilter.jsx @@ -0,0 +1,217 @@ +import React, { Fragment, useState, useRef } from 'react'; +import classNames from 'classnames'; +import { Transition } from '@headlessui/react'; + +import useOnClickOutside from '@hooks/useOnClickOutside'; +import { EOS_CLOSE } from 'eos-icons-react'; + +const oneHour = 60 * 60 * 1000; +const preconfiguredOptions = { + '1h ago': () => new Date(Date.now() - oneHour), + '24h ago': () => new Date(Date.now() - 24 * oneHour), + '7d ago': () => new Date(Date.now() - 7 * 24 * oneHour), + '30d ago': () => new Date(Date.now() - 30 * 24 * oneHour), +}; + +const renderOptionItem = (option, placeholder) => { + if (!option || !Array.isArray(option)) { + return placeholder; + } + if (typeof option[2] === 'function') { + return option[2](); + } + return option[0]; +}; + +const parseInputOptions = (options) => + options + .map((option) => { + if (typeof option === 'string' && option in preconfiguredOptions) { + return [option, preconfiguredOptions[option]]; + } + if ( + Array.isArray(option) && + typeof option[1] === 'function' && + option[1]() instanceof Date + ) { + return option; + } + return undefined; + }) + .filter(Boolean) + .reduceRight( + (acc, el) => + acc.find(([label]) => label === el[0]) ? acc : [...acc, el], + [] + ) + .sort((a, b) => b[1]().getTime() - a[1]().getTime()); + +const getSelectedOption = (options, value) => { + const selectedId = Array.isArray(value) ? value[0] : value; + if (typeof selectedId === 'string') { + return options.find((option) => option[0] === selectedId); + } + return undefined; +}; + +/** + * A component for filtering dates. + * + * @component + * @example + * // console.log(value)} + * // /> + * + * @param {Object} props - The component props. + * @param {Array} props.options - The options for the date filter. Each option can be a triple with a an id, a value function and an optional render function. + * The value function should return a Date object; the actual date value is calculated at selection time. + * In case the render function is not provided, the id will be used as the label. + * An option can also be a string, in which case it will be considered as a pre-configured option. + * In case more options with the same id are provided, only the last one will be considered. + * Options will be displayed sorted by date in descending order. + * @param {string} props.title - The title of the date filter, to be shown as placeholder when no value is selected. + * @param {string} props.value - The selected id of the selected option. It accepted either a string or an array with the id as the first element. + * @param {boolean} props.prefilled - Whether to include pre-configured options in the options list. Default is true. + * @param {function} props.onChange - The callback function to be called when the value of the date filter changes. It will provide a couple with the selected id and the actual date. + */ +function DateFilter({ + options = [], + title, + value, + prefilled = true, + onChange, +}) { + const ref = useRef(); + const [open, setOpen] = useState(false); + + const parsedOptions = parseInputOptions( + prefilled ? [...Object.entries(preconfiguredOptions), ...options] : options + ); + + const selectedOption = getSelectedOption(parsedOptions, value); + + useOnClickOutside(ref, () => setOpen(false)); + + return ( +
+
+ {selectedOption && ( + + )} + + +
+
+
    + {parsedOptions + .map((option) => ({ + key: option[0], + onItemClick: () => onChange([option[0], option[1]()]), + label: renderOptionItem(option), + isSelected: + selectedOption && selectedOption[0] === option[0], + })) + .map(({ key, label, isSelected, onItemClick }) => ( + + ))} +
+
+
+
+
+
+ ); +} + +export default DateFilter; diff --git a/assets/js/common/DateFilter/DateFilter.stories.jsx b/assets/js/common/DateFilter/DateFilter.stories.jsx new file mode 100644 index 0000000000..beb45ea06c --- /dev/null +++ b/assets/js/common/DateFilter/DateFilter.stories.jsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; + +import DateFilter from '.'; + +export default { + title: 'Components/DateFilter', + component: DateFilter, + argTypes: { + options: { + type: { name: 'array', required: true }, + description: 'List of options available', + control: { type: 'object' }, + }, + title: { + type: { name: 'string', required: true }, + description: + 'Title of the filter, will appear as placeholder when no value is selected', + control: { type: 'text' }, + }, + value: { + type: { name: 'array', required: false, defaultValue: [] }, + description: 'Selected options', + control: { type: 'object' }, + }, + onChange: { + type: { name: 'function', required: false }, + description: 'Function to call when the selected options change', + control: { type: null }, + }, + }, + render: (args) => { + const [value, setValue] = useState(args.value); + + return ( + { + setValue(newValue); + action('onChange')(newValue); + }} + /> + ); + }, +}; + +export const Default = { + args: { + title: 'by date', + }, +}; + +export const WithSelectedValue = { + args: { + ...Default.args, + value: '1h ago', + }, +}; + +export const WithCustomOptions = { + args: { + ...Default.args, + options: [['2h ago', () => new Date(Date.now() - 2 * 60 * 60 * 1000)]], + }, +}; + +export const WithOverriddenOptions = { + args: { + ...Default.args, + options: [ + [ + '30d ago', + () => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); + }, + () => 'One month ago', + ], + ], + }, +}; + +export const WithPickedOptionsOnly = { + args: { + ...Default.args, + prefilled: false, + options: ['1h ago', '30d ago'], + }, +}; + +export const WithCustomRenderer = { + args: { + ...Default.args, + prefilled: false, + options: [['epoch', () => new Date(0), () => '⌛ Beginning of time']], + }, +}; diff --git a/assets/js/common/DateFilter/DateFilter.test.jsx b/assets/js/common/DateFilter/DateFilter.test.jsx new file mode 100644 index 0000000000..c8000f16d9 --- /dev/null +++ b/assets/js/common/DateFilter/DateFilter.test.jsx @@ -0,0 +1,127 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import DateFilter from '.'; + +describe('DateFilter component', () => { + it('should render with pre-configured options', async () => { + const user = userEvent.setup(); + + render(); + + const placeholder = 'Filter by date...'; + + // Assert that the title is rendered + expect(screen.getByText(placeholder)).toBeInTheDocument(); + + await act(() => user.click(screen.getByText(placeholder))); + + // Assert that the options are rendered + ['1h ago', '24h ago', '7d ago', '30d ago'].forEach((label) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + + it('should select an option when clicked', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + + render(); + + await act(() => user.click(screen.getByText('Filter by date...'))); + + await act(() => user.click(screen.getByText('24h ago'))); + + expect(mockOnChange).toHaveBeenCalledWith(['24h ago', expect.any(Date)]); + }); + + it("should render the selected option's label when providing a string", async () => { + render( + + ); + + expect(screen.getByText('24h ago')).toBeInTheDocument(); + }); + + it("should render the selected option's label when providing an array", async () => { + render( + + ); + + expect(screen.getByText('24h ago')).toBeInTheDocument(); + }); + + it('should render custom option ', async () => { + const user = userEvent.setup(); + + render( + new Date(Date.now() - 42 * 24 * 60 * 60 * 1000)], + ]} + prefilled + onChange={jest.fn()} + /> + ); + + await act(() => user.click(screen.getByText('Filter by date...'))); + + expect(screen.getByText('Custom')).toBeInTheDocument(); + }); + + it.each` + option | labelToFind + ${'Custom'} | ${'Custom'} + ${['Custom', 'invalid' /* not a function */]} | ${'Custom'} + ${['Custom', () => 'invalid' /* not returning date */]} | ${'Custom'} + ${['Custom', () => undefined /* not returning date */]} | ${'Custom'} + ${['Custom', () => null /* not returning date */]} | ${'Custom'} + `('should not render malformed options', async ({ option, labelToFind }) => { + const user = userEvent.setup(); + + render( + + ); + + await act(() => user.click(screen.getByText('Filter by date...'))); + + expect(screen.queryByText(labelToFind)).not.toBeInTheDocument(); + }); + + it('should render overridden option', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + const anyDate = new Date(1986, 0, 24); + + render( + anyDate, () => 'my overridden item']]} + prefilled + onChange={mockOnChange} + /> + ); + + await act(() => user.click(screen.getByText('Filter by date...'))); + await act(() => user.click(screen.getByText('my overridden item'))); + + expect(mockOnChange).toHaveBeenCalledWith(['30d ago', anyDate]); + }); +}); diff --git a/assets/js/common/DateFilter/index.js b/assets/js/common/DateFilter/index.js new file mode 100644 index 0000000000..3dc0805fd8 --- /dev/null +++ b/assets/js/common/DateFilter/index.js @@ -0,0 +1,3 @@ +import DateFilter from './DateFilter'; + +export default DateFilter;