Updated sorting dropdown to accept an order object instead of two individual props

This commit is contained in:
Alejandro Celaya 2021-11-06 12:26:20 +01:00
parent 7169c6e083
commit ee826458be
7 changed files with 41 additions and 64 deletions

View file

@ -67,12 +67,7 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
return ( return (
<> <>
<div className="d-block d-lg-none mb-3"> <div className="d-block d-lg-none mb-3">
<SortingDropdown <SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
items={SORTABLE_FIELDS}
orderField={order.field}
orderDir={order.dir}
onChange={handleOrderBy}
/>
</div> </div>
<Card body className="pb-1"> <Card body className="pb-1">
<ShortUrlsTable <ShortUrlsTable

View file

@ -55,8 +55,11 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableP
); );
} }
const orderByColumn = (field: OrderableFields) => const orderByColumn = (field: OrderableFields) => () => {
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); const dir = determineOrderDir(field, order.field, order.dir);
setOrder({ field: dir ? field : undefined, dir });
};
const renderContent = () => { const renderContent = () => {
if (tagsList.filteredTags.length < 1) { if (tagsList.filteredTags.length < 1) {
@ -85,12 +88,7 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableP
<TagsModeDropdown mode={mode} onChange={setMode} /> <TagsModeDropdown mode={mode} onChange={setMode} />
</div> </div>
<div className="col-lg-6 mt-3 mt-lg-0"> <div className="col-lg-6 mt-3 mt-lg-0">
<SortingDropdown <SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={(field, dir) => setOrder({ field, dir })} />
items={SORTABLE_FIELDS}
orderField={order.field}
orderDir={order.dir}
onChange={(field, dir) => setOrder({ field, dir })}
/>
</div> </div>
</Row> </Row>
{renderContent()} {renderContent()}

View file

@ -3,23 +3,22 @@ import { toPairs } from 'ramda';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons'; import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { determineOrderDir, OrderDir } from './helpers/ordering'; import { determineOrderDir, Order, OrderDir } from './helpers/ordering';
import './SortingDropdown.scss'; import './SortingDropdown.scss';
export interface SortingDropdownProps<T extends string = string> { export interface SortingDropdownProps<T extends string = string> {
items: Record<T, string>; items: Record<T, string>;
orderField?: T; order: Order<T>;
orderDir?: OrderDir;
onChange: (orderField?: T, orderDir?: OrderDir) => void; onChange: (orderField?: T, orderDir?: OrderDir) => void;
isButton?: boolean; isButton?: boolean;
right?: boolean; right?: boolean;
} }
export default function SortingDropdown<T extends string = string>( export default function SortingDropdown<T extends string = string>(
{ items, orderField, orderDir, onChange, isButton = true, right = false }: SortingDropdownProps<T>, { items, order, onChange, isButton = true, right = false }: SortingDropdownProps<T>,
) { ) {
const handleItemClick = (fieldKey: T) => () => { const handleItemClick = (fieldKey: T) => () => {
const newOrderDir = determineOrderDir(fieldKey, orderField, orderDir); const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
onChange(newOrderDir ? fieldKey : undefined, newOrderDir); onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
}; };
@ -32,26 +31,26 @@ export default function SortingDropdown<T extends string = string>(
className={classNames({ 'dropdown-btn__toggle btn-block': isButton, 'btn-sm p-0': !isButton })} className={classNames({ 'dropdown-btn__toggle btn-block': isButton, 'btn-sm p-0': !isButton })}
> >
{!isButton && <>Order by</>} {!isButton && <>Order by</>}
{isButton && !orderField && <>Order by...</>} {isButton && !order.field && <>Order by...</>}
{isButton && orderField && `Order by: "${items[orderField]}" - "${orderDir ?? 'DESC'}"`} {isButton && order.field && `Order by: "${items[order.field]}" - "${order.dir ?? 'DESC'}"`}
</DropdownToggle> </DropdownToggle>
<DropdownMenu <DropdownMenu
right={right} right={right}
className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })} className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })}
> >
{toPairs(items).map(([ fieldKey, fieldValue ]) => ( {toPairs(items).map(([ fieldKey, fieldValue ]) => (
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey as T)}> <DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
{fieldValue} {fieldValue}
{orderField === fieldKey && ( {order.field === fieldKey && (
<FontAwesomeIcon <FontAwesomeIcon
icon={orderDir === 'ASC' ? sortAscIcon : sortDescIcon} icon={order.dir === 'ASC' ? sortAscIcon : sortDescIcon}
className="sorting-dropdown__sort-icon" className="sorting-dropdown__sort-icon"
/> />
)} )}
</DropdownItem> </DropdownItem>
))} ))}
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem disabled={!orderField} onClick={() => onChange()}> <DropdownItem disabled={!order.field} onClick={() => onChange()}>
<i>Clear selection</i> <i>Clear selection</i>
</DropdownItem> </DropdownItem>
</DropdownMenu> </DropdownMenu>

View file

@ -1,7 +1,7 @@
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda'; import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import { rangeOf } from '../../utils/utils'; import { rangeOf } from '../../utils/utils';
import { OrderDir } from '../../utils/helpers/ordering'; import { Order } from '../../utils/helpers/ordering';
import SimplePaginator from '../../common/SimplePaginator'; import SimplePaginator from '../../common/SimplePaginator';
import { roundTen } from '../../utils/helpers/numbers'; import { roundTen } from '../../utils/helpers/numbers';
import SortingDropdown from '../../utils/SortingDropdown'; import SortingDropdown from '../../utils/SortingDropdown';
@ -30,24 +30,21 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
withPagination = true, withPagination = true,
...rest ...rest
}) => { }) => {
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({ const [ order, setOrder ] = useState<Order<string>>({});
orderField: undefined,
orderDir: undefined,
});
const [ currentPage, setCurrentPage ] = useState(1); const [ currentPage, setCurrentPage ] = useState(1);
const [ itemsPerPage, setItemsPerPage ] = useState(50); const [ itemsPerPage, setItemsPerPage ] = useState(50);
const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => { const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => {
const pairs = toPairs(stats); const pairs = toPairs(stats);
const sortedPairs = !order.orderField ? pairs : sortBy( const sortedPairs = !order.field ? pairs : sortBy(
pipe<StatsRow, string | number, string | number>( pipe<StatsRow, string | number, string | number>(
order.orderField === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair, order.field === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair,
toLowerIfString, toLowerIfString,
), ),
pairs, pairs,
); );
return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs); return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs);
}; };
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => { const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
const page = pages[currentPage - 1]; const page = pages[currentPage - 1];
@ -103,10 +100,9 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
isButton={false} isButton={false}
right right
items={sortingItems} items={sortingItems}
orderField={order.orderField} order={order}
orderDir={order.orderDir} onChange={(field, dir) => {
onChange={(orderField, orderDir) => { setOrder({ field, dir });
setOrder({ orderField, orderDir });
setCurrentPage(1); setCurrentPage(1);
}} }}
/> />

View file

@ -108,23 +108,16 @@ describe('<ShortUrlsList />', () => {
}); });
it('handles order by through dropdown', () => { it('handles order by through dropdown', () => {
expect(wrapper.find(SortingDropdown).prop('orderField')).not.toBeDefined(); expect(wrapper.find(SortingDropdown).prop('order')).toEqual({});
expect(wrapper.find(SortingDropdown).prop('orderDir')).not.toBeDefined();
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC'); wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' });
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual('visits');
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual('ASC');
wrapper.find(SortingDropdown).simulate('change', 'shortCode', 'DESC'); wrapper.find(SortingDropdown).simulate('change', 'shortCode', 'DESC');
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'shortCode', dir: 'DESC' });
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual('shortCode');
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual('DESC');
wrapper.find(SortingDropdown).simulate('change', undefined, undefined); wrapper.find(SortingDropdown).simulate('change', undefined, undefined);
expect(wrapper.find(SortingDropdown).prop('order')).toEqual({});
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual(undefined);
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual(undefined);
expect(listShortUrlsMock).toHaveBeenCalledTimes(3); expect(listShortUrlsMock).toHaveBeenCalledTimes(3);
expect(listShortUrlsMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ expect(listShortUrlsMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
@ -140,10 +133,9 @@ describe('<ShortUrlsList />', () => {
[ Mock.of<OrderBy>({ visits: 'ASC' }), 'visits', 'ASC' ], [ Mock.of<OrderBy>({ visits: 'ASC' }), 'visits', 'ASC' ],
[ Mock.of<OrderBy>({ title: 'DESC' }), 'title', 'DESC' ], [ Mock.of<OrderBy>({ title: 'DESC' }), 'title', 'DESC' ],
[ Mock.of<OrderBy>(), undefined, undefined ], [ Mock.of<OrderBy>(), undefined, undefined ],
])('has expected initial ordering', (initialOrderBy, expectedField, expectedDir) => { ])('has expected initial ordering', (initialOrderBy, field, dir) => {
const wrapper = createWrapper(initialOrderBy); const wrapper = createWrapper(initialOrderBy);
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual(expectedField); expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field, dir });
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual(expectedDir);
}); });
}); });

View file

@ -89,14 +89,11 @@ describe('<TagsList />', () => {
it('triggers ordering when sorting dropdown changes', () => { it('triggers ordering when sorting dropdown changes', () => {
const wrapper = createWrapper({ filteredTags: [] }); const wrapper = createWrapper({ filteredTags: [] });
expect(wrapper.find(SortingDropdown).prop('orderField')).not.toBeDefined(); expect(wrapper.find(SortingDropdown).prop('order')).toEqual({});
expect(wrapper.find(SortingDropdown).prop('orderDir')).not.toBeDefined();
wrapper.find(SortingDropdown).simulate('change', 'tag', 'DESC'); wrapper.find(SortingDropdown).simulate('change', 'tag', 'DESC');
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual('tag'); expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'tag', dir: 'DESC' });
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual('DESC');
wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC'); wrapper.find(SortingDropdown).simulate('change', 'visits', 'ASC');
expect(wrapper.find(SortingDropdown).prop('orderField')).toEqual('visits'); expect(wrapper.find(SortingDropdown).prop('order')).toEqual({ field: 'visits', dir: 'ASC' });
expect(wrapper.find(SortingDropdown).prop('orderDir')).toEqual('ASC');
}); });
it('can update current order via orderByColumn from table component', () => { it('can update current order via orderByColumn from table component', () => {

View file

@ -14,7 +14,7 @@ describe('<SortingDropdown />', () => {
baz: 'Hello World', baz: 'Hello World',
}; };
const createWrapper = (props: Partial<SortingDropdownProps> = {}) => { const createWrapper = (props: Partial<SortingDropdownProps> = {}) => {
wrapper = shallow(<SortingDropdown items={items} onChange={identity} {...props} />); wrapper = shallow(<SortingDropdown items={items} order={{}} onChange={identity} {...props} />);
return wrapper; return wrapper;
}; };
@ -34,7 +34,7 @@ describe('<SortingDropdown />', () => {
}); });
it('properly marks selected field as active with proper icon', () => { it('properly marks selected field as active with proper icon', () => {
const wrapper = createWrapper({ orderField: 'bar', orderDir: 'DESC' }); const wrapper = createWrapper({ order: { field: 'bar', dir: 'DESC' } });
const activeItem = wrapper.find('DropdownItem[active=true]'); const activeItem = wrapper.find('DropdownItem[active=true]');
const activeItemIcon = activeItem.first().find(FontAwesomeIcon); const activeItemIcon = activeItem.first().find(FontAwesomeIcon);
@ -55,7 +55,7 @@ describe('<SortingDropdown />', () => {
it('triggers change function when item is clicked and an order field was provided', () => { it('triggers change function when item is clicked and an order field was provided', () => {
const onChange = jest.fn(); const onChange = jest.fn();
const wrapper = createWrapper({ onChange, orderField: 'baz', orderDir: 'ASC' }); const wrapper = createWrapper({ onChange, order: { field: 'baz', dir: 'ASC' } });
const firstItem = wrapper.find(DropdownItem).first(); const firstItem = wrapper.find(DropdownItem).first();
firstItem.simulate('click'); firstItem.simulate('click');
@ -66,7 +66,7 @@ describe('<SortingDropdown />', () => {
it('updates order dir when already selected item is clicked', () => { it('updates order dir when already selected item is clicked', () => {
const onChange = jest.fn(); const onChange = jest.fn();
const wrapper = createWrapper({ onChange, orderField: 'foo', orderDir: 'ASC' }); const wrapper = createWrapper({ onChange, order: { field: 'foo', dir: 'ASC' } });
const firstItem = wrapper.find(DropdownItem).first(); const firstItem = wrapper.find(DropdownItem).first();
firstItem.simulate('click'); firstItem.simulate('click');
@ -79,14 +79,14 @@ describe('<SortingDropdown />', () => {
[{ isButton: false }, <>Order by</> ], [{ isButton: false }, <>Order by</> ],
[{ isButton: true }, <>Order by...</> ], [{ isButton: true }, <>Order by...</> ],
[ [
{ isButton: true, orderField: 'foo', orderDir: 'ASC' as OrderDir }, { isButton: true, order: { field: 'foo', dir: 'ASC' as OrderDir } },
'Order by: "Foo" - "ASC"', 'Order by: "Foo" - "ASC"',
], ],
[ [
{ isButton: true, orderField: 'baz', orderDir: 'DESC' as OrderDir }, { isButton: true, order: { field: 'baz', dir: 'DESC' as OrderDir } },
'Order by: "Hello World" - "DESC"', 'Order by: "Hello World" - "DESC"',
], ],
[{ isButton: true, orderField: 'baz' }, 'Order by: "Hello World" - "DESC"' ], [{ isButton: true, order: { field: 'baz' } }, 'Order by: "Hello World" - "DESC"' ],
])('displays expected text in toggle', (props, expectedText) => { ])('displays expected text in toggle', (props, expectedText) => {
const wrapper = createWrapper(props); const wrapper = createWrapper(props);
const toggle = wrapper.find(DropdownToggle); const toggle = wrapper.find(DropdownToggle);