Skip to content

Commit

Permalink
Add <DateFilter > component (#2849)
Browse files Browse the repository at this point in the history
  • Loading branch information
balanza authored Aug 7, 2024
1 parent 2bcb35d commit 2ebad62
Show file tree
Hide file tree
Showing 6 changed files with 488 additions and 4 deletions.
24 changes: 20 additions & 4 deletions assets/js/common/ComposedFilter/ComposedFilter.jsx
Original file line number Diff line number Diff line change
@@ -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' ? (
<Filter key={key} {...filterProps} value={value} onChange={onChange} />
) : null;
const renderFilter = (key, { type, ...filterProps }, value, onChange) => {
switch (type) {
case 'select':
return (
<Filter key={key} {...filterProps} value={value} onChange={onChange} />
);
case 'date':
return (
<DateFilter
key={key}
{...filterProps}
value={value}
onChange={onChange}
/>
);
default:
return null;
}
};

/**
* Define a filter which is the composition of several filters.
Expand Down
21 changes: 21 additions & 0 deletions assets/js/common/ComposedFilter/ComposedFilter.stories.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
},
};
217 changes: 217 additions & 0 deletions assets/js/common/DateFilter/DateFilter.jsx
Original file line number Diff line number Diff line change
@@ -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
* // <DateFilter
* // options={['1h ago', '24h ago', '7d ago', '30d ago']}
* // title="Date"
* // value="24h ago"
* // prefilled
* // onChange={(value) => 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 (
<div className="flex-1 w-64 top-16" ref={ref}>
<div className="mt-1 relative">
{selectedOption && (
<button
type="button"
aria-label="Clear filter"
data-testid={`filter-${title}-clear`}
className="block absolute z-20 right-0 h-full pr-2 flex items-center"
onClick={() => onChange(undefined)}
>
<EOS_CLOSE
size="20"
className="text-gray-400 hover:text-gray-500"
color="currentColor"
/>
</button>
)}
<button
type="button"
data-testid={`filter-${title}`}
onClick={() => setOpen(!open)}
className="relative w-full bg-white hover:bg-gray-50 rounded-md border pl-3 pr-10 py-3 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-jungle-green-500 focus:border-jungle-green-500 sm:text-sm"
>
<span className="flex items-center">
<span
className={classNames('ml-3 block truncate', {
'text-gray-500': !value,
})}
>
{renderOptionItem(selectedOption, `Filter ${title}...`)}
</span>
</span>
<span className="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
{!selectedOption && (
<svg
className="h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
)}
</span>
</button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={open}
>
<div className="absolute mt-1 w-full z-10 rounded-md bg-white shadow-lg">
<div className="ring-1 ring-black ring-opacity-5 rounded-md">
<ul
tabIndex="-1"
role="listbox"
data-testid={`filter-${title}-options`}
aria-labelledby="listbox-label"
className="max-h-56 py-2 text-base overflow-auto focus:outline-none sm:text-sm"
>
{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 }) => (
<li
key={key}
role="option"
aria-selected={isSelected}
aria-hidden="true"
className="text-gray-900 cursor-default select-none hover:bg-jungle-green-500 hover:text-white relative py-2 pl-3 pr-9"
onClick={onItemClick}
>
<div className="flex items-center">
<span className="ml-3 block font-normal truncate">
{label}
</span>
</div>
{isSelected && (
<span className="absolute inset-y-0 right-0 flex items-center pr-4">
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</li>
))}
</ul>
</div>
</div>
</Transition>
</div>
</div>
);
}

export default DateFilter;
100 changes: 100 additions & 0 deletions assets/js/common/DateFilter/DateFilter.stories.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<DateFilter
title={args.title}
options={args.options}
value={value}
prefilled={args.prefilled}
onChange={(newValue) => {
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']],
},
};
Loading

0 comments on commit 2ebad62

Please sign in to comment.