Sammy/t 194 aau when i set sort back and forth the (#103)

* bugfix: use original row id in cells to make sure it rerenders

* feature: implement multiple sorts

* bugfix: recreate new array to make sure component rerenders

* feature: orderBy is an array to keep orders

* test: snapshot the searchTemplate methods

* feature: remove the console log and return undefined

* feature: use orderByTemplate instead of hardcoded orderBy

* refactor: move sort and where filters helpers out of service

* refactor: rename file helper

* refactor: move assert function in test
This commit is contained in:
Sammy Teillet 2023-05-05 16:22:47 +02:00 committed by GitHub
parent f022bf8335
commit b8cd842633
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 253 additions and 156 deletions

View File

@ -87,7 +87,7 @@ const StyledTableScrollableContainer = styled.div`
flex: 1;
`;
function Table<TData, SortField extends string, FilterProperies>({
function Table<TData extends { id: string }, SortField, FilterProperies>({
data,
columns,
viewName,
@ -140,7 +140,7 @@ function Table<TData, SortField extends string, FilterProperies>({
<tr key={row.id} data-testid={`row-id-${row.index}`}>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id}>
<td key={cell.id + row.original.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),

View File

@ -84,10 +84,11 @@ export function FilterDropdownButton<FilterProperties>({
label: selectedFilter.label,
value: value.displayValue,
icon: selectedFilter.icon,
where: selectedFilter.whereTemplate(
where:
selectedFilter.whereTemplate(
selectedFilterOperand,
value.value,
),
) || ({} as FilterProperties),
searchResultMapper: selectedFilter.searchResultMapper,
});
setIsUnfolded(false);

View File

@ -41,7 +41,7 @@ const StyledCancelButton = styled.button`
}
`;
function SortAndFilterBar<SortField extends string, FilterProperties>({
function SortAndFilterBar<SortField, FilterProperties>({
sorts,
onRemoveSort,
filters,

View File

@ -8,9 +8,9 @@ type OwnProps<SortField> = {
availableSorts: SortType<SortField>[];
};
const options: Array<SelectedSortType<string>['order']> = ['asc', 'desc'];
const options: Array<SelectedSortType<any>['order']> = ['asc', 'desc'];
export function SortDropdownButton<SortField extends string>({
export function SortDropdownButton<SortField>({
isSortSelected,
availableSorts,
onSortSelect,

View File

@ -66,7 +66,7 @@ const StyledFilters = styled.div`
margin-right: ${(props) => props.theme.spacing(2)};
`;
function TableHeader<SortField extends string, FilterProperties>({
function TableHeader<SortField, FilterProperties>({
viewName,
viewIcon,
availableSorts,
@ -84,37 +84,40 @@ function TableHeader<SortField extends string, FilterProperties>({
>([]);
const sortSelect = useCallback(
(sort: SelectedSortType<SortField>) => {
innerSetSorts([sort]);
onSortsUpdate && onSortsUpdate([sort]);
},
[onSortsUpdate],
);
const sortUnselect = useCallback(
(sortId: string) => {
const newSorts = [] as SelectedSortType<SortField>[];
(newSort: SelectedSortType<SortField>) => {
const newSorts = updateSortOrFilterByKey(sorts, newSort);
innerSetSorts(newSorts);
onSortsUpdate && onSortsUpdate(newSorts);
},
[onSortsUpdate],
[onSortsUpdate, sorts],
);
const sortUnselect = useCallback(
(sortKey: string) => {
const newSorts = sorts.filter((sort) => sort.key !== sortKey);
innerSetSorts(newSorts);
onSortsUpdate && onSortsUpdate(newSorts);
},
[onSortsUpdate, sorts],
);
const filterSelect = useCallback(
(filter: SelectedFilterType<FilterProperties>) => {
innerSetFilters([filter]);
onFiltersUpdate && onFiltersUpdate([filter]);
const newFilters = updateSortOrFilterByKey(filters, filter);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
},
[onFiltersUpdate],
[onFiltersUpdate, filters],
);
const filterUnselect = useCallback(
(filterId: SelectedFilterType<FilterProperties>['key']) => {
const newFilters = [] as SelectedFilterType<FilterProperties>[];
const newFilters = filters.filter((filter) => filter.key !== filterId);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
},
[onFiltersUpdate],
[onFiltersUpdate, filters],
);
const filterSearch = useCallback(
@ -161,3 +164,19 @@ function TableHeader<SortField extends string, FilterProperties>({
}
export default TableHeader;
function updateSortOrFilterByKey<SortOrFilter extends { key: string }>(
sorts: Readonly<SortOrFilter[]>,
newSort: SortOrFilter,
): SortOrFilter[] {
const newSorts = [...sorts];
const existingSortIndex = sorts.findIndex((sort) => sort.key === newSort.key);
if (existingSortIndex !== -1) {
newSorts[existingSortIndex] = newSort;
} else {
newSorts.push(newSort);
}
return newSorts;
}

View File

@ -25,12 +25,14 @@ export const RegularSortAndFilterBar = ({ removeFunction }: OwnProps) => {
order: 'asc',
key: 'test_sort',
icon: <FaArrowDown />,
_type: 'default_sort',
},
{
label: 'Test sort 2',
order: 'desc',
key: 'test_sort_2',
icon: <FaArrowDown />,
_type: 'default_sort',
},
]}
onRemoveSort={removeFunction}

View File

@ -11,6 +11,7 @@ import {
} from 'react-icons/fa';
import { SortDropdownButton } from '../SortDropdownButton';
import styled from '@emotion/styled';
import { Order_By, People_Order_By } from '../../../../generated/graphql';
const component = {
title: 'SortDropdownButton',
@ -28,25 +29,31 @@ const availableSorts = [
key: 'fullname',
label: 'People',
icon: <FaRegUser />,
_type: 'custom_sort',
orderByTemplate: () => ({ email: Order_By.Asc }),
},
{
key: 'company_name',
label: 'Company',
icon: <FaRegBuilding />,
_type: 'custom_sort',
orderByTemplate: () => ({ email: Order_By.Asc }),
},
{
key: 'email',
label: 'Email',
icon: <FaEnvelope />,
_type: 'default_sort',
},
{ key: 'phone', label: 'Phone', icon: <FaPhone /> },
{ key: 'phone', label: 'Phone', icon: <FaPhone />, _type: 'default_sort' },
{
key: 'created_at',
label: 'Created at',
icon: <FaCalendar />,
_type: 'default_sort',
},
{ key: 'city', label: 'City', icon: <FaMapPin /> },
] satisfies SortType[];
{ key: 'city', label: 'City', icon: <FaMapPin />, _type: 'default_sort' },
] satisfies SortType<People_Order_By>[];
const StyleDiv = styled.div`
height: 200px;
@ -57,7 +64,7 @@ export const RegularSortDropdownButton = ({ setSorts }: OwnProps) => {
return (
<ThemeProvider theme={lightTheme}>
<StyleDiv>
<SortDropdownButton
<SortDropdownButton<People_Order_By>
isSortSelected={true}
availableSorts={availableSorts}
onSortSelect={setSorts}

View File

@ -12,11 +12,12 @@ const component = {
export default component;
export const RegularTableHeader = () => {
const availableSorts: Array<SortType> = [
const availableSorts: Array<SortType<Record<'created_at', 'asc'>>> = [
{
key: 'created_at',
label: 'Created at',
icon: <FaCalendar />,
_type: 'default_sort',
},
];
return (

View File

@ -19,6 +19,7 @@ it('Checks the default top option is Ascending', async () => {
key: 'email',
icon: <FaEnvelope />,
order: 'asc',
_type: 'default_sort',
});
});
@ -45,5 +46,6 @@ it('Checks the selection of Descending', async () => {
key: 'email',
icon: <FaEnvelope />,
order: 'desc',
_type: 'default_sort',
});
});

View File

@ -0,0 +1,33 @@
import { Order_By } from '../../../generated/graphql';
import { SelectedFilterType, SelectedSortType } from './interface';
export const reduceFiltersToWhere = <T>(
filters: Array<SelectedFilterType<T>>,
): T => {
const where = filters.reduce((acc, filter) => {
const { where } = filter;
return { ...acc, ...where };
}, {} as T);
return where;
};
const mapOrderToOrder_By = (order: string) => {
if (order === 'asc') return Order_By.Asc;
return Order_By.Desc;
};
export const defaultOrderByTemplateFactory =
(key: string) => (order: string) => ({
[key]: order,
});
export const reduceSortsToOrderBy = <OrderByTemplate>(
sorts: Array<SelectedSortType<OrderByTemplate>>,
): OrderByTemplate[] => {
const mappedSorts = sorts.map((sort) => {
if (sort._type === 'custom_sort')
return sort.orderByTemplate(mapOrderToOrder_By(sort.order));
return defaultOrderByTemplateFactory(sort.key as string)(sort.order);
});
return mappedSorts as OrderByTemplate[];
};

View File

@ -2,23 +2,27 @@ import { DocumentNode } from 'graphql';
import { ReactNode } from 'react';
import {
Companies_Bool_Exp,
Order_By,
People_Bool_Exp,
Users_Bool_Exp,
} from '../../../generated/graphql';
import { GraphqlQueryCompany } from '../../../interfaces/company.interface';
import {
SEARCH_COMPANY_QUERY,
SEARCH_PEOPLE_QUERY,
} from '../../../services/search/search';
import { GraphqlQueryPerson } from '../../../interfaces/person.interface';
export type SortType<SortKey = string> = {
export type SortType<OrderByTemplate> =
| {
_type: 'default_sort';
label: string;
key: SortKey;
key: keyof OrderByTemplate & string;
icon?: ReactNode;
};
}
| {
_type: 'custom_sort';
label: string;
key: string;
icon?: ReactNode;
orderByTemplate: (order: Order_By) => OrderByTemplate;
};
export type SelectedSortType<SortField = string> = SortType<SortField> & {
export type SelectedSortType<OrderByTemplate> = SortType<OrderByTemplate> & {
order: 'asc' | 'desc';
};
@ -30,7 +34,7 @@ export type FilterType<WhereTemplate, FilterValue = Record<string, any>> = {
whereTemplate: (
operand: FilterOperandType,
value: FilterValue,
) => WhereTemplate;
) => WhereTemplate | undefined;
searchQuery: DocumentNode;
searchTemplate: (
searchInput: string,
@ -52,25 +56,3 @@ export type SelectedFilterType<WhereTemplate> = FilterType<WhereTemplate> & {
operand: FilterOperandType;
where: WhereTemplate;
};
export function assertFilterUseCompanySearch<FilterValue>(
filter: FilterType<People_Bool_Exp>,
): filter is FilterType<People_Bool_Exp> & {
searchResultMapper: (data: GraphqlQueryCompany) => {
displayValue: string;
value: FilterValue;
};
} {
return filter.searchQuery === SEARCH_COMPANY_QUERY;
}
export function assertFilterUsePeopleSearch<FilterValue>(
filter: FilterType<People_Bool_Exp>,
): filter is FilterType<People_Bool_Exp> & {
searchResultMapper: (data: GraphqlQueryPerson) => {
displayValue: string;
value: FilterValue;
};
} {
return filter.searchQuery === SEARCH_PEOPLE_QUERY;
}

View File

@ -5,12 +5,12 @@ import { useState, useCallback } from 'react';
import {
CompaniesSelectedSortType,
defaultOrderBy,
reduceSortsToOrderBy,
useCompaniesQuery,
} from '../../services/companies';
import Table from '../../components/table/Table';
import { mapCompany } from '../../interfaces/company.interface';
import { companiesColumns, sortsAvailable } from './companies-table';
import { reduceSortsToOrderBy } from '../../components/table/table-header/helpers';
const StyledCompaniesContainer = styled.div`
display: flex;

View File

@ -1,6 +1,6 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Company } from '../../interfaces/company.interface';
import { OrderByFields, updateCompany } from '../../services/companies';
import { updateCompany } from '../../services/companies';
import ColumnHead from '../../components/table/ColumnHead';
import Checkbox from '../../components/form/Checkbox';
import CompanyChip from '../../components/chips/CompanyChip';
@ -17,21 +17,24 @@ import {
} from 'react-icons/fa';
import ClickableCell from '../../components/table/ClickableCell';
import PersonChip from '../../components/chips/PersonChip';
import { SortType } from '../../components/table/table-header/interface';
import EditableChip from '../../components/table/editable-cell/EditableChip';
import { SortType } from '../../components/table/table-header/interface';
import { Companies_Order_By } from '../../generated/graphql';
export const sortsAvailable = [
{
key: 'name',
label: 'Name',
icon: undefined,
_type: 'default_sort',
},
{
key: 'domain_name',
label: 'Domain',
icon: undefined,
_type: 'default_sort',
},
] satisfies Array<SortType<OrderByFields>>;
] satisfies Array<SortType<Companies_Order_By>>;
const columnHelper = createColumnHelper<Company>();
export const companiesColumns = [

View File

@ -12,13 +12,15 @@ import { useCallback, useState } from 'react';
import {
PeopleSelectedSortType,
defaultOrderBy,
reduceFiltersToWhere,
reduceSortsToOrderBy,
usePeopleQuery,
} from '../../services/people';
import { useSearch } from '../../services/search/search';
import { People_Bool_Exp } from '../../generated/graphql';
import { SelectedFilterType } from '../../components/table/table-header/interface';
import {
reduceFiltersToWhere,
reduceSortsToOrderBy,
} from '../../components/table/table-header/helpers';
const StyledPeopleContainer = styled.div`
display: flex;

View File

@ -93,3 +93,44 @@ Object {
},
}
`;
exports[`PeopleFilter should render the serch city with the searchValue 1`] = `
Object {
"city": Object {
"_ilike": "%Search value%",
},
}
`;
exports[`PeopleFilter should render the serch company_name with the searchValue 1`] = `
Object {
"name": Object {
"_ilike": "%Search value%",
},
}
`;
exports[`PeopleFilter should render the serch email with the searchValue 1`] = `
Object {
"email": Object {
"_ilike": "%Search value%",
},
}
`;
exports[`PeopleFilter should render the serch fullname with the searchValue 1`] = `
Object {
"_or": Array [
Object {
"firstname": Object {
"_ilike": "%Search value%",
},
},
Object {
"lastname": Object {
"_ilike": "%Search value%",
},
},
],
}
`;

View File

@ -1,12 +1,37 @@
import {
assertFilterUseCompanySearch,
assertFilterUsePeopleSearch,
} from '../../../components/table/table-header/interface';
import { FilterType } from '../../../components/table/table-header/interface';
import { People_Bool_Exp } from '../../../generated/graphql';
import { GraphqlQueryCompany } from '../../../interfaces/company.interface';
import { GraphqlQueryPerson } from '../../../interfaces/person.interface';
import {
SEARCH_COMPANY_QUERY,
SEARCH_PEOPLE_QUERY,
} from '../../../services/search/search';
import { mockCompanyData } from '../../companies/__stories__/mock-data';
import { defaultData } from '../default-data';
import { availableFilters } from '../people-table';
function assertFilterUseCompanySearch<FilterValue>(
filter: FilterType<People_Bool_Exp>,
): filter is FilterType<People_Bool_Exp> & {
searchResultMapper: (data: GraphqlQueryCompany) => {
displayValue: string;
value: FilterValue;
};
} {
return filter.searchQuery === SEARCH_COMPANY_QUERY;
}
function assertFilterUsePeopleSearch<FilterValue>(
filter: FilterType<People_Bool_Exp>,
): filter is FilterType<People_Bool_Exp> & {
searchResultMapper: (data: GraphqlQueryPerson) => {
displayValue: string;
value: FilterValue;
};
} {
return filter.searchQuery === SEARCH_PEOPLE_QUERY;
}
const JohnDoeUser = defaultData.find(
(user) => user.email === 'john@linkedin.com',
) as GraphqlQueryPerson;
@ -33,5 +58,8 @@ describe('PeopleFilter', () => {
}
}
});
it(`should render the serch ${filter.key} with the searchValue`, () => {
expect(filter.searchTemplate('Search value')).toMatchSnapshot();
});
}
});

View File

@ -17,12 +17,16 @@ import CompanyChip from '../../components/chips/CompanyChip';
import { GraphqlQueryPerson, Person } from '../../interfaces/person.interface';
import PipeChip from '../../components/chips/PipeChip';
import EditableText from '../../components/table/editable-cell/EditableText';
import { OrderByFields, updatePerson } from '../../services/people';
import { updatePerson } from '../../services/people';
import {
FilterType,
SortType,
} from '../../components/table/table-header/interface';
import { People_Bool_Exp } from '../../generated/graphql';
import {
Order_By,
People_Bool_Exp,
People_Order_By,
} from '../../generated/graphql';
import {
SEARCH_COMPANY_QUERY,
SEARCH_PEOPLE_QUERY,
@ -36,25 +40,44 @@ export const availableSorts = [
key: 'fullname',
label: 'People',
icon: <FaRegUser />,
_type: 'custom_sort',
orderByTemplate: (order: Order_By) => ({
firstname: order,
lastname: order,
}),
},
{
key: 'company_name',
label: 'Company',
icon: <FaRegBuilding />,
_type: 'custom_sort',
orderByTemplate: (order: Order_By) => ({ company: { name: order } }),
},
{
key: 'email',
label: 'Email',
icon: <FaEnvelope />,
_type: 'default_sort',
},
{
key: 'phone',
label: 'Phone',
icon: <FaPhone />,
_type: 'default_sort',
},
{ key: 'phone', label: 'Phone', icon: <FaPhone /> },
{
key: 'created_at',
label: 'Created at',
icon: <FaCalendar />,
_type: 'default_sort',
},
{ key: 'city', label: 'City', icon: <FaMapPin /> },
] satisfies Array<SortType<OrderByFields>>;
{
key: 'city',
label: 'City',
icon: <FaMapPin />,
_type: 'default_sort',
},
] satisfies Array<SortType<People_Order_By>>;
const fullnameFilter = {
key: 'fullname',
@ -80,8 +103,6 @@ const fullnameFilter = {
},
};
}
console.error(Error(`Unhandled operand: ${operand.keyWord}`));
return {};
},
searchQuery: SEARCH_PEOPLE_QUERY,
searchTemplate: (searchInput: string) => ({
@ -116,8 +137,6 @@ const companyFilter = {
_not: { company: { name: { _eq: companyName } } },
};
}
console.error(Error(`Unhandled operand: ${operand.keyWord}`));
return {};
},
searchQuery: SEARCH_COMPANY_QUERY,
searchTemplate: (searchInput: string) => ({
@ -149,8 +168,6 @@ const emailFilter = {
_not: { email: { _eq: email } },
};
}
console.error(Error(`Unhandled operand: ${operand.keyWord}`));
return {};
},
searchQuery: SEARCH_PEOPLE_QUERY,
searchTemplate: (searchInput: string) => ({
@ -182,8 +199,6 @@ const cityFilter = {
_not: { city: { _eq: city } },
};
}
console.error(Error(`Unhandled operand: ${operand.keyWord}`));
return {};
},
searchQuery: SEARCH_PEOPLE_QUERY,
searchTemplate: (searchInput: string) => ({

View File

@ -1,12 +1,18 @@
import { CompaniesSelectedSortType, reduceSortsToOrderBy } from './select';
import { reduceSortsToOrderBy } from '../../components/table/table-header/helpers';
import { CompaniesSelectedSortType } from './select';
describe('reduceSortsToOrderBy', () => {
it('should return an array of objects with the id as key and the order as value', () => {
const sorts = [
{ key: 'name', label: 'name', order: 'asc' },
{ key: 'domain_name', label: 'domain_name', order: 'desc' },
{ key: 'name', label: 'name', order: 'asc', _type: 'default_sort' },
{
key: 'domain_name',
label: 'domain_name',
order: 'desc',
_type: 'default_sort',
},
] satisfies CompaniesSelectedSortType[];
const result = reduceSortsToOrderBy(sorts);
expect(result).toEqual([{ name: 'asc', domain_name: 'desc' }]);
expect(result).toEqual([{ name: 'asc' }, { domain_name: 'desc' }]);
});
});

View File

@ -3,25 +3,7 @@ import { Order_By, Companies_Order_By } from '../../generated/graphql';
import { GraphqlQueryCompany } from '../../interfaces/company.interface';
import { SelectedSortType } from '../../components/table/table-header/interface';
export type OrderByFields = keyof Companies_Order_By | 'domain_name' | 'name';
export type CompaniesSelectedSortType = SelectedSortType<OrderByFields>;
const mapOrder = (order: 'asc' | 'desc'): Order_By => {
return order === 'asc' ? Order_By.Asc : Order_By.Desc;
};
export const reduceSortsToOrderBy = (
sorts: Array<CompaniesSelectedSortType>,
): Companies_Order_By[] => {
const mappedSorts = sorts.reduce((acc, sort) => {
const id = sort.key;
const order = mapOrder(sort.order);
acc[id] = order;
return acc;
}, {} as Companies_Order_By);
return [mappedSorts];
};
export type CompaniesSelectedSortType = SelectedSortType<Companies_Order_By>;
export const GET_COMPANIES = gql`
query GetCompanies($orderBy: [companies_order_by!]) {

View File

@ -1,12 +1,23 @@
import { PeopleSelectedSortType, reduceSortsToOrderBy } from './select';
import { reduceSortsToOrderBy } from '../../components/table/table-header/helpers';
import { PeopleSelectedSortType } from './select';
describe('reduceSortsToOrderBy', () => {
it('should return an array of objects with the id as key and the order as value', () => {
const sorts = [
{ key: 'firstname', label: 'firstname', order: 'asc' },
{ key: 'lastname', label: 'lastname', order: 'desc' },
{
key: 'firstname',
label: 'firstname',
order: 'asc',
_type: 'default_sort',
},
{
key: 'lastname',
label: 'lastname',
order: 'desc',
_type: 'default_sort',
},
] satisfies PeopleSelectedSortType[];
const result = reduceSortsToOrderBy(sorts);
expect(result).toEqual([{ firstname: 'asc', lastname: 'desc' }]);
expect(result).toEqual([{ firstname: 'asc' }, { lastname: 'desc' }]);
});
});

View File

@ -5,47 +5,9 @@ import {
People_Bool_Exp,
People_Order_By,
} from '../../generated/graphql';
import {
SelectedFilterType,
SelectedSortType,
} from '../../components/table/table-header/interface';
import { SelectedSortType } from '../../components/table/table-header/interface';
export type OrderByFields = keyof People_Order_By | 'fullname' | 'company_name';
export type PeopleSelectedSortType = SelectedSortType<OrderByFields>;
const mapOrder = (order: 'asc' | 'desc'): Order_By => {
return order === 'asc' ? Order_By.Asc : Order_By.Desc;
};
export const reduceFiltersToWhere = <T>(
filters: Array<SelectedFilterType<T>>,
): T => {
const where = filters.reduce((acc, filter) => {
const { where } = filter;
return { ...acc, ...where };
}, {} as T);
return where;
};
export const reduceSortsToOrderBy = (
sorts: Array<PeopleSelectedSortType>,
): People_Order_By[] => {
const mappedSorts = sorts.reduce((acc, sort) => {
const id = sort.key;
const order = mapOrder(sort.order);
if (id === 'fullname') {
acc['firstname'] = order;
acc['lastname'] = order;
} else if (id === 'company_name') {
acc['company'] = { name: order };
} else {
acc[id] = order;
}
return acc;
}, {} as People_Order_By);
return [mappedSorts];
};
export type PeopleSelectedSortType = SelectedSortType<People_Order_By>;
export const GET_PEOPLE = gql`
query GetPeople(