Add Filters on Table views (#95)
* Add filter search logic WIP Filter search Implement filters test: fix sorts tests test: fix filter test feature: search person and display firstname in results feature: fix test for filter component test: mock search filters refactor: create a useSearch hook refactor: move debounce in useSearch and reset status of filter selection feature: debounce set filters refactor: remove useless setSorts feature: add where variable to people query feature: strongly type Filters feature: update WhereTemplate method feature: implement filtering on full name feature: type the useSearch hook feature: use where reducer refactor: create a type for readability feature: use query and mapper from filters feature: implement filter by company feature: search filter results on filter select feature: add loading and results to search results in filters refactor: move render search results in a function feature: display a LOADING when it loads feature: split search input and search filter for different debounce refactor: remove some warnings refactor: remove some warnings * Write test 1 * Write test 2 * test: useSearch is tested * test: update names of default people data * test: add a filter search * Test 3 * Fix tests --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
66
front/src/__stories__/App.stories.tsx
Normal file
66
front/src/__stories__/App.stories.tsx
Normal file
File diff suppressed because one or more lines are too long
17
front/src/__tests__/App.test.tsx
Normal file
17
front/src/__tests__/App.test.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { RegularApp } from '../__stories__/App.stories';
|
||||
|
||||
const assignMock = jest.fn();
|
||||
|
||||
delete window.location;
|
||||
window.location = { assign: assignMock };
|
||||
|
||||
it('Checks the App component renders', async () => {
|
||||
const { getByText } = render(<RegularApp />);
|
||||
|
||||
expect(getByText('Companies')).toBeDefined();
|
||||
expect(getByText('Opportunities')).toBeDefined();
|
||||
await waitFor(() => {
|
||||
expect(getByText('Twenty')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Opportunity } from '../../interfaces/company.interface';
|
||||
|
||||
type OwnProps = {
|
||||
name: string;
|
||||
picture?: string;
|
||||
opportunity: Opportunity;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.span`
|
||||
@ -20,11 +20,11 @@ const StyledContainer = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
function PipeChip({ name, picture }: OwnProps) {
|
||||
function PipeChip({ opportunity }: OwnProps) {
|
||||
return (
|
||||
<StyledContainer data-testid="company-chip">
|
||||
{picture && <span>{picture}</span>}
|
||||
<span>{name}</span>
|
||||
<StyledContainer data-testid="company-chip" key={opportunity.id}>
|
||||
{opportunity.icon && <span>{opportunity.icon}</span>}
|
||||
<span>{opportunity.name}</span>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,18 +10,30 @@ import TableHeader from './table-header/TableHeader';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
FilterType,
|
||||
SelectedFilterType,
|
||||
SelectedSortType,
|
||||
SortType,
|
||||
} from './table-header/interface';
|
||||
|
||||
type OwnProps<TData, SortField> = {
|
||||
type OwnProps<TData, SortField, FilterProperties> = {
|
||||
data: Array<TData>;
|
||||
columns: Array<ColumnDef<TData, any>>;
|
||||
viewName: string;
|
||||
viewIcon?: React.ReactNode;
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
availableSorts?: Array<SortType<SortField>>;
|
||||
availableFilters?: FilterType[];
|
||||
availableFilters?: FilterType<FilterProperties>[];
|
||||
filterSearchResults?: {
|
||||
results: { displayValue: string; value: any }[];
|
||||
loading: boolean;
|
||||
};
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
onFiltersUpdate?: (
|
||||
sorts: Array<SelectedFilterType<FilterProperties>>,
|
||||
) => void;
|
||||
onFilterSearch?: (
|
||||
filter: FilterType<FilterProperties> | null,
|
||||
searchValue: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const StyledTable = styled.table`
|
||||
@ -75,15 +87,18 @@ const StyledTableScrollableContainer = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
function Table<TData, SortField extends string>({
|
||||
function Table<TData, SortField extends string, FilterProperies>({
|
||||
data,
|
||||
columns,
|
||||
viewName,
|
||||
viewIcon,
|
||||
onSortsUpdate,
|
||||
availableSorts,
|
||||
availableFilters,
|
||||
}: OwnProps<TData, SortField>) {
|
||||
filterSearchResults,
|
||||
onSortsUpdate,
|
||||
onFiltersUpdate,
|
||||
onFilterSearch,
|
||||
}: OwnProps<TData, SortField, FilterProperies>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@ -95,9 +110,12 @@ function Table<TData, SortField extends string>({
|
||||
<TableHeader
|
||||
viewName={viewName}
|
||||
viewIcon={viewIcon}
|
||||
onSortsUpdate={onSortsUpdate}
|
||||
availableSorts={availableSorts}
|
||||
availableFilters={availableFilters}
|
||||
filterSearchResults={filterSearchResults}
|
||||
onSortsUpdate={onSortsUpdate}
|
||||
onFiltersUpdate={onFiltersUpdate}
|
||||
onFilterSearch={onFilterSearch}
|
||||
/>
|
||||
<StyledTableScrollableContainer>
|
||||
<StyledTable>
|
||||
|
||||
@ -1,24 +1,31 @@
|
||||
import EditableCell from '../EditableCell';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { lightTheme } from '../../../layout/styles/themes';
|
||||
import { StoryFn } from '@storybook/react';
|
||||
|
||||
const component = {
|
||||
title: 'EditableCell',
|
||||
component: EditableCell,
|
||||
};
|
||||
|
||||
export default component;
|
||||
|
||||
type OwnProps = {
|
||||
changeHandler: () => void;
|
||||
content: string;
|
||||
changeHandler: (updated: string) => void;
|
||||
};
|
||||
|
||||
export const RegularEditableCell = ({ changeHandler }: OwnProps) => {
|
||||
export default component;
|
||||
|
||||
const Template: StoryFn<typeof EditableCell> = (args: OwnProps) => {
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<div data-testid="content-editable-parent">
|
||||
<EditableCell content={''} changeHandler={changeHandler} />,
|
||||
<EditableCell {...args} />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditableCellStory = Template.bind({});
|
||||
EditableCellStory.args = {
|
||||
content: 'Test string',
|
||||
};
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import { RegularEditableCell } from '../__stories__/EditableCell.stories';
|
||||
import { EditableCellStory } from '../__stories__/EditableCell.stories';
|
||||
|
||||
it('Checks the EditableCell editing event bubbles up', async () => {
|
||||
const func = jest.fn(() => null);
|
||||
const { getByTestId } = render(<RegularEditableCell changeHandler={func} />);
|
||||
const { getByTestId } = render(
|
||||
<EditableCellStory content="test" changeHandler={func} />,
|
||||
);
|
||||
|
||||
const parent = getByTestId('content-editable-parent');
|
||||
const editableInput = parent.querySelector('input');
|
||||
|
||||
@ -1,47 +1,40 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChangeEvent, useCallback, useState } from 'react';
|
||||
import DropdownButton from './DropdownButton';
|
||||
import { FilterType, SelectedFilterType } from './interface';
|
||||
import { FilterOperandType, FilterType, SelectedFilterType } from './interface';
|
||||
|
||||
type OwnProps = {
|
||||
filters: SelectedFilterType[];
|
||||
setFilters: (sorts: SelectedFilterType[]) => void;
|
||||
availableFilters: FilterType[];
|
||||
type OwnProps<FilterProperties> = {
|
||||
isFilterSelected: boolean;
|
||||
availableFilters: FilterType<FilterProperties>[];
|
||||
filterSearchResults?: {
|
||||
results: { displayValue: string; value: any }[];
|
||||
loading: boolean;
|
||||
};
|
||||
onFilterSelect: (filter: SelectedFilterType<FilterProperties>) => void;
|
||||
onFilterSearch: (
|
||||
filter: FilterType<FilterProperties> | null,
|
||||
searchValue: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type FilterOperandType = { label: string; id: string };
|
||||
|
||||
const filterOperands: FilterOperandType[] = [
|
||||
{ label: 'Include', id: 'include' },
|
||||
{ label: "Doesn't include", id: 'not-include' },
|
||||
{ label: 'Include', id: 'include', keyWord: 'ilike' },
|
||||
{ label: "Doesn't include", id: 'not-include', keyWord: 'not_ilike' },
|
||||
];
|
||||
|
||||
const someFieldRandomValue = [
|
||||
'John Doe',
|
||||
'Jane Doe',
|
||||
'John Smith',
|
||||
'Jane Smith',
|
||||
'John Johnson',
|
||||
'Jane Johnson',
|
||||
'John Williams',
|
||||
'Jane Williams',
|
||||
'John Brown',
|
||||
'Jane Brown',
|
||||
'John Jones',
|
||||
'Jane Jones',
|
||||
];
|
||||
|
||||
export function FilterDropdownButton({
|
||||
export function FilterDropdownButton<FilterProperties>({
|
||||
availableFilters,
|
||||
setFilters,
|
||||
filters,
|
||||
}: OwnProps) {
|
||||
filterSearchResults,
|
||||
onFilterSearch,
|
||||
onFilterSelect,
|
||||
isFilterSelected,
|
||||
}: OwnProps<FilterProperties>) {
|
||||
const [isUnfolded, setIsUnfolded] = useState(false);
|
||||
|
||||
const [isOptionUnfolded, setIsOptionUnfolded] = useState(false);
|
||||
|
||||
const [selectedFilter, setSelectedFilter] = useState<FilterType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [selectedFilter, setSelectedFilter] = useState<
|
||||
FilterType<FilterProperties> | undefined
|
||||
>(undefined);
|
||||
|
||||
const [selectedFilterOperand, setSelectedFilterOperand] =
|
||||
useState<FilterOperandType>(filterOperands[0]);
|
||||
@ -50,7 +43,8 @@ export function FilterDropdownButton({
|
||||
setIsOptionUnfolded(false);
|
||||
setSelectedFilter(undefined);
|
||||
setSelectedFilterOperand(filterOperands[0]);
|
||||
}, []);
|
||||
onFilterSearch(null, '');
|
||||
}, [onFilterSearch]);
|
||||
|
||||
const renderSelectOptionItems = filterOperands.map((filterOperand, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
@ -64,11 +58,49 @@ export function FilterDropdownButton({
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
));
|
||||
|
||||
const renderSearchResults = (
|
||||
filterSearchResults: NonNullable<
|
||||
OwnProps<FilterProperties>['filterSearchResults']
|
||||
>,
|
||||
selectedFilter: FilterType<FilterProperties>,
|
||||
) => {
|
||||
if (filterSearchResults.loading) {
|
||||
return 'LOADING';
|
||||
}
|
||||
return filterSearchResults.results.map((value, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={`fields-value-${index}`}
|
||||
onClick={() => {
|
||||
onFilterSelect({
|
||||
key: value.displayValue,
|
||||
operand: selectedFilterOperand,
|
||||
searchQuery: selectedFilter.searchQuery,
|
||||
searchTemplate: selectedFilter.searchTemplate,
|
||||
whereTemplate: selectedFilter.whereTemplate,
|
||||
label: selectedFilter.label,
|
||||
value: value.displayValue,
|
||||
icon: selectedFilter.icon,
|
||||
where: selectedFilter.whereTemplate(
|
||||
selectedFilterOperand,
|
||||
value.value,
|
||||
),
|
||||
searchResultMapper: selectedFilter.searchResultMapper,
|
||||
});
|
||||
setIsUnfolded(false);
|
||||
setSelectedFilter(undefined);
|
||||
}}
|
||||
>
|
||||
{value.displayValue}
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
));
|
||||
};
|
||||
|
||||
const renderSelectFilterITems = availableFilters.map((filter, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={`select-filter-${index}`}
|
||||
onClick={() => {
|
||||
setSelectedFilter(filter);
|
||||
onFilterSearch(filter, '');
|
||||
}}
|
||||
>
|
||||
<DropdownButton.StyledIcon>{filter.icon}</DropdownButton.StyledIcon>
|
||||
@ -76,46 +108,36 @@ export function FilterDropdownButton({
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
));
|
||||
|
||||
function renderFilterDropdown(selectedFilter: FilterType) {
|
||||
return [
|
||||
<DropdownButton.StyledDropdownTopOption
|
||||
key={'selected-filter-operand'}
|
||||
onClick={() => setIsOptionUnfolded(true)}
|
||||
>
|
||||
{selectedFilterOperand.label}
|
||||
|
||||
<DropdownButton.StyledDropdownTopOptionAngleDown />
|
||||
</DropdownButton.StyledDropdownTopOption>,
|
||||
<DropdownButton.StyledSearchField key={'search-filter'}>
|
||||
<input type="text" placeholder={selectedFilter.label} />
|
||||
</DropdownButton.StyledSearchField>,
|
||||
someFieldRandomValue.map((value, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={`fields-value-${index}`}
|
||||
onClick={() => {
|
||||
setFilters([
|
||||
{
|
||||
id: value,
|
||||
operand: selectedFilterOperand,
|
||||
label: selectedFilter.label,
|
||||
value: value,
|
||||
icon: selectedFilter.icon,
|
||||
},
|
||||
]);
|
||||
setIsUnfolded(false);
|
||||
setSelectedFilter(undefined);
|
||||
}}
|
||||
function renderFilterDropdown(selectedFilter: FilterType<FilterProperties>) {
|
||||
return (
|
||||
<>
|
||||
<DropdownButton.StyledDropdownTopOption
|
||||
key={'selected-filter-operand'}
|
||||
onClick={() => setIsOptionUnfolded(true)}
|
||||
>
|
||||
{value}
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
)),
|
||||
];
|
||||
{selectedFilterOperand.label}
|
||||
|
||||
<DropdownButton.StyledDropdownTopOptionAngleDown />
|
||||
</DropdownButton.StyledDropdownTopOption>
|
||||
<DropdownButton.StyledSearchField key={'search-filter'}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={selectedFilter.label}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
onFilterSearch(selectedFilter, event.target.value)
|
||||
}
|
||||
/>
|
||||
</DropdownButton.StyledSearchField>
|
||||
{filterSearchResults &&
|
||||
renderSearchResults(filterSearchResults, selectedFilter)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownButton
|
||||
label="Filter"
|
||||
isActive={filters.length > 0}
|
||||
isActive={isFilterSelected}
|
||||
isUnfolded={isUnfolded}
|
||||
setIsUnfolded={setIsUnfolded}
|
||||
resetState={resetState}
|
||||
|
||||
@ -3,11 +3,13 @@ import SortOrFilterChip from './SortOrFilterChip';
|
||||
import { FaArrowDown, FaArrowUp } from 'react-icons/fa';
|
||||
import { SelectedFilterType, SelectedSortType } from './interface';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
type OwnProps<SortField, FilterProperties> = {
|
||||
sorts: Array<SelectedSortType<SortField>>;
|
||||
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
|
||||
filters: Array<SelectedFilterType>;
|
||||
onRemoveFilter: (filterId: SelectedFilterType['id']) => void;
|
||||
filters: Array<SelectedFilterType<FilterProperties>>;
|
||||
onRemoveFilter: (
|
||||
filterId: SelectedFilterType<FilterProperties>['key'],
|
||||
) => void;
|
||||
};
|
||||
|
||||
const StyledBar = styled.div`
|
||||
@ -39,12 +41,12 @@ const StyledCancelButton = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
function SortAndFilterBar<SortField extends string>({
|
||||
function SortAndFilterBar<SortField extends string, FilterProperties>({
|
||||
sorts,
|
||||
onRemoveSort,
|
||||
filters,
|
||||
onRemoveFilter,
|
||||
}: OwnProps<SortField>) {
|
||||
}: OwnProps<SortField, FilterProperties>) {
|
||||
return (
|
||||
<StyledBar>
|
||||
{sorts.map((sort) => {
|
||||
@ -61,12 +63,12 @@ function SortAndFilterBar<SortField extends string>({
|
||||
{filters.map((filter) => {
|
||||
return (
|
||||
<SortOrFilterChip
|
||||
key={filter.id}
|
||||
key={filter.key}
|
||||
labelKey={filter.label}
|
||||
labelValue={`${filter.operand.label} ${filter.value}`}
|
||||
id={filter.id}
|
||||
id={filter.key}
|
||||
icon={filter.icon}
|
||||
onRemove={() => onRemoveFilter(filter.id)}
|
||||
onRemove={() => onRemoveFilter(filter.key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -75,7 +77,7 @@ function SortAndFilterBar<SortField extends string>({
|
||||
data-testid={'cancel-button'}
|
||||
onClick={() => {
|
||||
sorts.forEach((i) => onRemoveSort(i.key));
|
||||
filters.forEach((i) => onRemoveFilter(i.id));
|
||||
filters.forEach((i) => onRemoveFilter(i.key));
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@ -3,17 +3,17 @@ import DropdownButton from './DropdownButton';
|
||||
import { SelectedSortType, SortType } from './interface';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
sorts: SelectedSortType<SortField>[];
|
||||
setSorts: (sorts: SelectedSortType<SortField>[]) => void;
|
||||
isSortSelected: boolean;
|
||||
onSortSelect: (sort: SelectedSortType<SortField>) => void;
|
||||
availableSorts: SortType<SortField>[];
|
||||
};
|
||||
|
||||
const options: Array<SelectedSortType<string>['order']> = ['asc', 'desc'];
|
||||
|
||||
export function SortDropdownButton<SortField extends string>({
|
||||
isSortSelected,
|
||||
availableSorts,
|
||||
setSorts,
|
||||
sorts,
|
||||
onSortSelect,
|
||||
}: OwnProps<SortField>) {
|
||||
const [isUnfolded, setIsUnfolded] = useState(false);
|
||||
|
||||
@ -24,10 +24,9 @@ export function SortDropdownButton<SortField extends string>({
|
||||
|
||||
const onSortItemSelect = useCallback(
|
||||
(sort: SortType<SortField>) => {
|
||||
const newSorts = [{ ...sort, order: selectedSortDirection }];
|
||||
setSorts(newSorts);
|
||||
onSortSelect({ ...sort, order: selectedSortDirection });
|
||||
},
|
||||
[setSorts, selectedSortDirection],
|
||||
[onSortSelect, selectedSortDirection],
|
||||
);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
@ -38,7 +37,7 @@ export function SortDropdownButton<SortField extends string>({
|
||||
return (
|
||||
<DropdownButton
|
||||
label="Sort"
|
||||
isActive={sorts.length > 0}
|
||||
isActive={isSortSelected}
|
||||
isUnfolded={isUnfolded}
|
||||
setIsUnfolded={setIsUnfolded}
|
||||
resetState={resetState}
|
||||
|
||||
@ -11,13 +11,23 @@ import { SortDropdownButton } from './SortDropdownButton';
|
||||
import { FilterDropdownButton } from './FilterDropdownButton';
|
||||
import SortAndFilterBar from './SortAndFilterBar';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
type OwnProps<SortField, FilterProperties> = {
|
||||
viewName: string;
|
||||
viewIcon?: ReactNode;
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
onFiltersUpdate?: (sorts: Array<SelectedFilterType>) => void;
|
||||
availableSorts?: Array<SortType<SortField>>;
|
||||
availableFilters?: FilterType[];
|
||||
availableFilters?: FilterType<FilterProperties>[];
|
||||
filterSearchResults?: {
|
||||
results: { displayValue: string; value: any }[];
|
||||
loading: boolean;
|
||||
};
|
||||
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
|
||||
onFiltersUpdate?: (
|
||||
sorts: Array<SelectedFilterType<FilterProperties>>,
|
||||
) => void;
|
||||
onFilterSearch?: (
|
||||
filter: FilterType<FilterProperties> | null,
|
||||
searchValue: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@ -56,27 +66,32 @@ const StyledFilters = styled.div`
|
||||
margin-right: ${(props) => props.theme.spacing(2)};
|
||||
`;
|
||||
|
||||
function TableHeader<SortField extends string>({
|
||||
function TableHeader<SortField extends string, FilterProperties>({
|
||||
viewName,
|
||||
viewIcon,
|
||||
onSortsUpdate,
|
||||
onFiltersUpdate,
|
||||
availableSorts,
|
||||
availableFilters,
|
||||
}: OwnProps<SortField>) {
|
||||
filterSearchResults,
|
||||
onSortsUpdate,
|
||||
onFiltersUpdate,
|
||||
onFilterSearch,
|
||||
}: OwnProps<SortField, FilterProperties>) {
|
||||
const [sorts, innerSetSorts] = useState<Array<SelectedSortType<SortField>>>(
|
||||
[],
|
||||
);
|
||||
const [filters, innerSetFilters] = useState<
|
||||
Array<SelectedFilterType<FilterProperties>>
|
||||
>([]);
|
||||
|
||||
const setSorts = useCallback(
|
||||
(sorts: SelectedSortType<SortField>[]) => {
|
||||
innerSetSorts(sorts);
|
||||
onSortsUpdate && onSortsUpdate(sorts);
|
||||
const sortSelect = useCallback(
|
||||
(sort: SelectedSortType<SortField>) => {
|
||||
innerSetSorts([sort]);
|
||||
onSortsUpdate && onSortsUpdate([sort]);
|
||||
},
|
||||
[onSortsUpdate],
|
||||
);
|
||||
|
||||
const onSortItemUnSelect = useCallback(
|
||||
const sortUnselect = useCallback(
|
||||
(sortId: string) => {
|
||||
const newSorts = [] as SelectedSortType<SortField>[];
|
||||
innerSetSorts(newSorts);
|
||||
@ -85,25 +100,30 @@ function TableHeader<SortField extends string>({
|
||||
[onSortsUpdate],
|
||||
);
|
||||
|
||||
const [filters, innerSetFilters] = useState<Array<SelectedFilterType>>([]);
|
||||
|
||||
const setFilters = useCallback(
|
||||
(filters: SelectedFilterType[]) => {
|
||||
innerSetFilters(filters);
|
||||
onFiltersUpdate && onFiltersUpdate(filters);
|
||||
const filterSelect = useCallback(
|
||||
(filter: SelectedFilterType<FilterProperties>) => {
|
||||
innerSetFilters([filter]);
|
||||
onFiltersUpdate && onFiltersUpdate([filter]);
|
||||
},
|
||||
[onFiltersUpdate],
|
||||
);
|
||||
|
||||
const onFilterItemUnSelect = useCallback(
|
||||
(filterId: SelectedFilterType['id']) => {
|
||||
const newFilters = [] as SelectedFilterType[];
|
||||
const filterUnselect = useCallback(
|
||||
(filterId: SelectedFilterType<FilterProperties>['key']) => {
|
||||
const newFilters = [] as SelectedFilterType<FilterProperties>[];
|
||||
innerSetFilters(newFilters);
|
||||
onFiltersUpdate && onFiltersUpdate(newFilters);
|
||||
},
|
||||
[onFiltersUpdate],
|
||||
);
|
||||
|
||||
const filterSearch = useCallback(
|
||||
(filter: FilterType<FilterProperties> | null, searchValue: string) => {
|
||||
onFilterSearch && onFilterSearch(filter, searchValue);
|
||||
},
|
||||
[onFilterSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledTableHeader>
|
||||
@ -113,14 +133,16 @@ function TableHeader<SortField extends string>({
|
||||
</StyledViewSection>
|
||||
<StyledFilters>
|
||||
<FilterDropdownButton
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
isFilterSelected={filters.length > 0}
|
||||
availableFilters={availableFilters || []}
|
||||
filterSearchResults={filterSearchResults}
|
||||
onFilterSelect={filterSelect}
|
||||
onFilterSearch={filterSearch}
|
||||
/>
|
||||
<SortDropdownButton
|
||||
setSorts={setSorts}
|
||||
sorts={sorts}
|
||||
<SortDropdownButton<SortField>
|
||||
isSortSelected={sorts.length > 0}
|
||||
availableSorts={availableSorts || []}
|
||||
onSortSelect={sortSelect}
|
||||
/>
|
||||
|
||||
<DropdownButton label="Settings" isActive={false}></DropdownButton>
|
||||
@ -129,9 +151,9 @@ function TableHeader<SortField extends string>({
|
||||
{sorts.length + filters.length > 0 && (
|
||||
<SortAndFilterBar
|
||||
sorts={sorts}
|
||||
onRemoveSort={onSortItemUnSelect}
|
||||
filters={filters}
|
||||
onRemoveFilter={onFilterItemUnSelect}
|
||||
onRemoveSort={sortUnselect}
|
||||
onRemoveFilter={filterUnselect}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
|
||||
@ -3,15 +3,15 @@ import { lightTheme } from '../../../../layout/styles/themes';
|
||||
import { FilterDropdownButton } from '../FilterDropdownButton';
|
||||
import styled from '@emotion/styled';
|
||||
import { FilterType, SelectedFilterType } from '../interface';
|
||||
import {
|
||||
FaRegUser,
|
||||
FaRegBuilding,
|
||||
FaEnvelope,
|
||||
FaPhone,
|
||||
FaCalendar,
|
||||
FaMapPin,
|
||||
} from 'react-icons/fa';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { People_Bool_Exp } from '../../../../generated/graphql';
|
||||
import { FaUsers } from 'react-icons/fa';
|
||||
import {
|
||||
SEARCH_PEOPLE_QUERY,
|
||||
useSearch,
|
||||
} from '../../../../services/search/search';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { defaultData } from '../../../../pages/people/default-data';
|
||||
|
||||
const component = {
|
||||
title: 'FilterDropdownButton',
|
||||
@ -20,58 +20,129 @@ const component = {
|
||||
|
||||
export default component;
|
||||
|
||||
type OwnProps = {
|
||||
setFilters: (filters: SelectedFilterType[]) => void;
|
||||
type OwnProps<FilterProperties> = {
|
||||
setFilter: (filters: SelectedFilterType<FilterProperties>) => void;
|
||||
};
|
||||
|
||||
const mocks = [
|
||||
{
|
||||
request: {
|
||||
query: SEARCH_PEOPLE_QUERY, // TODO this should not be called for empty filters
|
||||
variables: {
|
||||
where: undefined,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
searchResults: defaultData,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SEARCH_PEOPLE_QUERY, // TODO this should not be called for empty filters
|
||||
variables: {
|
||||
where: {
|
||||
_or: [
|
||||
{ firstname: { _ilike: '%%' } },
|
||||
{ lastname: { _ilike: '%%' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
searchResults: defaultData,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SEARCH_PEOPLE_QUERY, // TODO this should not be called for empty filters
|
||||
variables: {
|
||||
where: {
|
||||
_or: [
|
||||
{ firstname: { _ilike: '%Jane%' } },
|
||||
{ lastname: { _ilike: '%Jane%' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
searchResults: [defaultData.find((p) => p.firstname === 'Jane')],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const availableFilters = [
|
||||
{
|
||||
key: 'fullname',
|
||||
label: 'People',
|
||||
icon: <FaRegUser />,
|
||||
icon: <FaUsers />,
|
||||
searchQuery: SEARCH_PEOPLE_QUERY,
|
||||
searchTemplate: (searchInput: string) => ({
|
||||
_or: [
|
||||
{ firstname: { _ilike: `%${searchInput}%` } },
|
||||
{ lastname: { _ilike: `%${searchInput}%` } },
|
||||
],
|
||||
}),
|
||||
whereTemplate: () => ({
|
||||
_or: [
|
||||
{ firstname: { _ilike: 'value' } },
|
||||
{ lastname: { _ilike: 'value' } },
|
||||
],
|
||||
}),
|
||||
searchResultMapper: (data) => ({
|
||||
displayValue: data.firstname + ' ' + data.lastname,
|
||||
value: data.firstname,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
label: 'Company',
|
||||
icon: <FaRegBuilding />,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
icon: <FaEnvelope />,
|
||||
},
|
||||
{ key: 'phone', label: 'Phone', icon: <FaPhone /> },
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created at',
|
||||
icon: <FaCalendar />,
|
||||
},
|
||||
{ key: 'city', label: 'City', icon: <FaMapPin /> },
|
||||
] satisfies FilterType[];
|
||||
] satisfies FilterType<People_Bool_Exp>[];
|
||||
|
||||
const StyleDiv = styled.div`
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
`;
|
||||
|
||||
export const RegularFilterDropdownButton = ({ setFilters }: OwnProps) => {
|
||||
const [filters, innerSetFilters] = useState<SelectedFilterType[]>([]);
|
||||
const InnerRegularFilterDropdownButton = ({
|
||||
setFilter: setFilters,
|
||||
}: OwnProps<People_Bool_Exp>) => {
|
||||
const [, innerSetFilters] = useState<SelectedFilterType<People_Bool_Exp>>();
|
||||
const [filterSearchResults, setSearhInput, setFilterSearch] = useSearch();
|
||||
|
||||
const outerSetFilters = useCallback(
|
||||
(filters: SelectedFilterType[]) => {
|
||||
innerSetFilters(filters);
|
||||
setFilters(filters);
|
||||
(filter: SelectedFilterType<People_Bool_Exp>) => {
|
||||
innerSetFilters(filter);
|
||||
setFilters(filter);
|
||||
},
|
||||
[setFilters],
|
||||
);
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<StyleDiv>
|
||||
<FilterDropdownButton
|
||||
availableFilters={availableFilters}
|
||||
filters={filters}
|
||||
setFilters={outerSetFilters}
|
||||
/>
|
||||
</StyleDiv>
|
||||
</ThemeProvider>
|
||||
<StyleDiv>
|
||||
<FilterDropdownButton
|
||||
availableFilters={availableFilters}
|
||||
isFilterSelected={true}
|
||||
onFilterSelect={outerSetFilters}
|
||||
filterSearchResults={filterSearchResults}
|
||||
onFilterSearch={(filter, searchValue) => {
|
||||
setSearhInput(searchValue);
|
||||
setFilterSearch(filter);
|
||||
}}
|
||||
/>
|
||||
</StyleDiv>
|
||||
);
|
||||
};
|
||||
|
||||
export const RegularFilterDropdownButton = ({
|
||||
setFilter: setFilters,
|
||||
}: OwnProps<People_Bool_Exp>) => {
|
||||
return (
|
||||
<MockedProvider mocks={mocks}>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<InnerRegularFilterDropdownButton setFilter={setFilters} />
|
||||
</ThemeProvider>
|
||||
</MockedProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import SortAndFilterBar from '../SortAndFilterBar';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { lightTheme } from '../../../../layout/styles/themes';
|
||||
import { GET_PEOPLE } from '../../../../services/people';
|
||||
import { FaArrowDown } from 'react-icons/fa';
|
||||
|
||||
const component = {
|
||||
@ -37,10 +38,24 @@ export const RegularSortAndFilterBar = ({ removeFunction }: OwnProps) => {
|
||||
filters={[
|
||||
{
|
||||
label: 'People',
|
||||
operand: { id: 'include', label: 'Include' },
|
||||
id: 'test_filter',
|
||||
operand: { label: 'Include', id: 'include', keyWord: 'ilike' },
|
||||
key: 'test_filter',
|
||||
icon: <FaArrowDown />,
|
||||
value: 'John Doe',
|
||||
where: {
|
||||
firstname: { _ilike: 'John Doe' },
|
||||
},
|
||||
searchQuery: GET_PEOPLE,
|
||||
searchTemplate: () => ({
|
||||
firstname: { _ilike: 'John Doe' },
|
||||
}),
|
||||
whereTemplate: () => {
|
||||
return { firstname: { _ilike: 'John Doe' } };
|
||||
},
|
||||
searchResultMapper: (data) => ({
|
||||
displayValue: 'John Doe',
|
||||
value: data.firstname,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SelectedSortType, SortType } from '../interface';
|
||||
import { SortType } from '../interface';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { lightTheme } from '../../../../layout/styles/themes';
|
||||
import {
|
||||
@ -23,8 +23,6 @@ type OwnProps = {
|
||||
setSorts: () => void;
|
||||
};
|
||||
|
||||
const sorts = [] satisfies SelectedSortType[];
|
||||
|
||||
const availableSorts = [
|
||||
{
|
||||
key: 'fullname',
|
||||
@ -60,9 +58,9 @@ export const RegularSortDropdownButton = ({ setSorts }: OwnProps) => {
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<StyleDiv>
|
||||
<SortDropdownButton
|
||||
sorts={sorts}
|
||||
isSortSelected={true}
|
||||
availableSorts={availableSorts}
|
||||
setSorts={setSorts}
|
||||
onSortSelect={setSorts}
|
||||
/>
|
||||
</StyleDiv>
|
||||
</ThemeProvider>
|
||||
|
||||
@ -1,44 +1,53 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { RegularFilterDropdownButton } from '../__stories__/FilterDropdownButton.stories';
|
||||
import { FaEnvelope } from 'react-icons/fa';
|
||||
import { FaUsers } from 'react-icons/fa';
|
||||
|
||||
it('Checks the default top option is Include', async () => {
|
||||
const setSorts = jest.fn();
|
||||
const setFilters = jest.fn();
|
||||
const { getByText } = render(
|
||||
<RegularFilterDropdownButton setFilters={setSorts} />,
|
||||
<RegularFilterDropdownButton setFilter={setFilters} />,
|
||||
);
|
||||
|
||||
const sortDropdownButton = getByText('Filter');
|
||||
fireEvent.click(sortDropdownButton);
|
||||
|
||||
const sortByEmail = getByText('Email');
|
||||
fireEvent.click(sortByEmail);
|
||||
const filterByPeople = getByText('People');
|
||||
fireEvent.click(filterByPeople);
|
||||
|
||||
const filterByJohn = getByText('John Doe');
|
||||
await waitFor(() => {
|
||||
const firstSearchResult = getByText('Alexandre Prot');
|
||||
expect(firstSearchResult).toBeDefined();
|
||||
});
|
||||
|
||||
const filterByJohn = getByText('Alexandre Prot');
|
||||
fireEvent.click(filterByJohn);
|
||||
|
||||
expect(setSorts).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'John Doe',
|
||||
value: 'John Doe',
|
||||
label: 'Email',
|
||||
operand: { id: 'include', label: 'Include' },
|
||||
icon: <FaEnvelope />,
|
||||
},
|
||||
]);
|
||||
expect(setFilters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'Alexandre Prot',
|
||||
value: 'Alexandre Prot',
|
||||
label: 'People',
|
||||
operand: {
|
||||
id: 'include',
|
||||
keyWord: 'ilike',
|
||||
label: 'Include',
|
||||
},
|
||||
icon: <FaUsers />,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('Checks the selection of top option for Doesnot include', async () => {
|
||||
const setSorts = jest.fn();
|
||||
const setFilters = jest.fn();
|
||||
const { getByText } = render(
|
||||
<RegularFilterDropdownButton setFilters={setSorts} />,
|
||||
<RegularFilterDropdownButton setFilter={setFilters} />,
|
||||
);
|
||||
|
||||
const sortDropdownButton = getByText('Filter');
|
||||
fireEvent.click(sortDropdownButton);
|
||||
|
||||
const sortByEmail = getByText('Email');
|
||||
fireEvent.click(sortByEmail);
|
||||
const filterByPeople = getByText('People');
|
||||
fireEvent.click(filterByPeople);
|
||||
|
||||
const openOperandOptions = getByText('Include');
|
||||
fireEvent.click(openOperandOptions);
|
||||
@ -46,19 +55,80 @@ it('Checks the selection of top option for Doesnot include', async () => {
|
||||
const selectOperand = getByText("Doesn't include");
|
||||
fireEvent.click(selectOperand);
|
||||
|
||||
const filterByJohn = getByText('John Doe');
|
||||
await waitFor(() => {
|
||||
const firstSearchResult = getByText('Alexandre Prot');
|
||||
expect(firstSearchResult).toBeDefined();
|
||||
});
|
||||
|
||||
const filterByJohn = getByText('Alexandre Prot');
|
||||
fireEvent.click(filterByJohn);
|
||||
|
||||
expect(setSorts).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'John Doe',
|
||||
value: 'John Doe',
|
||||
label: 'Email',
|
||||
operand: { id: 'not-include', label: "Doesn't include" },
|
||||
icon: <FaEnvelope />,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(setFilters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'Alexandre Prot',
|
||||
value: 'Alexandre Prot',
|
||||
label: 'People',
|
||||
operand: {
|
||||
id: 'not-include',
|
||||
keyWord: 'not_ilike',
|
||||
label: "Doesn't include",
|
||||
},
|
||||
icon: <FaUsers />,
|
||||
}),
|
||||
);
|
||||
const blueSortDropdownButton = getByText('Filter');
|
||||
await waitFor(() => {
|
||||
expect(blueSortDropdownButton).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('Calls the filters when typing a new name', async () => {
|
||||
const setFilters = jest.fn();
|
||||
const { getByText, getByPlaceholderText, queryByText } = render(
|
||||
<RegularFilterDropdownButton setFilter={setFilters} />,
|
||||
);
|
||||
|
||||
const sortDropdownButton = getByText('Filter');
|
||||
fireEvent.click(sortDropdownButton);
|
||||
|
||||
const filterByPeople = getByText('People');
|
||||
fireEvent.click(filterByPeople);
|
||||
|
||||
const filterSearch = getByPlaceholderText('People');
|
||||
fireEvent.click(filterSearch);
|
||||
|
||||
fireEvent.change(filterSearch, { target: { value: 'Jane' } });
|
||||
|
||||
await waitFor(() => {
|
||||
const loadingDiv = getByText('LOADING');
|
||||
expect(loadingDiv).toBeDefined();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const firstSearchResult = getByText('Jane Doe');
|
||||
expect(firstSearchResult).toBeDefined();
|
||||
|
||||
const alexandreSearchResult = queryByText('Alexandre Prot');
|
||||
expect(alexandreSearchResult).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const filterByJane = getByText('Jane Doe');
|
||||
|
||||
fireEvent.click(filterByJane);
|
||||
|
||||
expect(setFilters).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'Jane Doe',
|
||||
value: 'Jane Doe',
|
||||
label: 'People',
|
||||
operand: {
|
||||
id: 'include',
|
||||
keyWord: 'ilike',
|
||||
label: 'Include',
|
||||
},
|
||||
icon: <FaUsers />,
|
||||
}),
|
||||
);
|
||||
const blueSortDropdownButton = getByText('Filter');
|
||||
await waitFor(() => {
|
||||
expect(blueSortDropdownButton).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
@ -14,14 +14,12 @@ it('Checks the default top option is Ascending', async () => {
|
||||
const sortByEmail = getByText('Email');
|
||||
fireEvent.click(sortByEmail);
|
||||
|
||||
expect(setSorts).toHaveBeenCalledWith([
|
||||
{
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
icon: <FaEnvelope />,
|
||||
order: 'asc',
|
||||
},
|
||||
]);
|
||||
expect(setSorts).toHaveBeenCalledWith({
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
icon: <FaEnvelope />,
|
||||
order: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
it('Checks the selection of Descending', async () => {
|
||||
@ -42,12 +40,10 @@ it('Checks the selection of Descending', async () => {
|
||||
const sortByEmail = getByText('Email');
|
||||
fireEvent.click(sortByEmail);
|
||||
|
||||
expect(setSorts).toHaveBeenCalledWith([
|
||||
{
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
icon: <FaEnvelope />,
|
||||
order: 'desc',
|
||||
},
|
||||
]);
|
||||
expect(setSorts).toHaveBeenCalledWith({
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
icon: <FaEnvelope />,
|
||||
order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { DocumentNode } from 'graphql';
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
Companies_Bool_Exp,
|
||||
People_Bool_Exp,
|
||||
Users_Bool_Exp,
|
||||
} from '../../../generated/graphql';
|
||||
|
||||
export type SortType<SortKey = string> = {
|
||||
label: string;
|
||||
@ -6,20 +12,30 @@ export type SortType<SortKey = string> = {
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export type FilterType<FilterKey = string> = {
|
||||
label: string;
|
||||
key: FilterKey;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export type SelectedFilterType = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
operand: { id: string; label: string };
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export type SelectedSortType<SortField = string> = SortType<SortField> & {
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export type FilterType<WhereTemplate, T = Record<string, string>> = {
|
||||
label: string;
|
||||
key: string;
|
||||
icon: ReactNode;
|
||||
whereTemplate: (operand: FilterOperandType, value: T) => WhereTemplate;
|
||||
searchQuery: DocumentNode;
|
||||
searchTemplate: (
|
||||
searchInput: string,
|
||||
) => People_Bool_Exp | Companies_Bool_Exp | Users_Bool_Exp;
|
||||
searchResultMapper: (data: any) => { displayValue: string; value: T };
|
||||
};
|
||||
|
||||
export type FilterOperandType = {
|
||||
label: string;
|
||||
id: string;
|
||||
keyWord: 'ilike' | 'not_ilike';
|
||||
};
|
||||
|
||||
export type SelectedFilterType<WhereTemplate> = FilterType<WhereTemplate> & {
|
||||
value: string;
|
||||
operand: FilterOperandType;
|
||||
where: WhereTemplate;
|
||||
};
|
||||
|
||||
@ -13,6 +13,7 @@ describe('mapCompany', () => {
|
||||
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
|
||||
email: 'john@example.com',
|
||||
displayName: 'John Doe',
|
||||
__typename: 'User',
|
||||
},
|
||||
employees: 10,
|
||||
address: '1 Infinite Loop, 95014 Cupertino, California, USA',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { User } from './user.interface';
|
||||
import { GraphqlQueryUser, User } from './user.interface';
|
||||
|
||||
export interface Opportunity {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
@ -16,17 +17,11 @@ export interface Company {
|
||||
creationDate: Date;
|
||||
}
|
||||
|
||||
export type GraphqlQueryAccountOwner = {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type GraphqlQueryCompany = {
|
||||
id: string;
|
||||
name: string;
|
||||
domain_name: string;
|
||||
account_owner?: GraphqlQueryAccountOwner;
|
||||
account_owner?: GraphqlQueryUser;
|
||||
employees: number;
|
||||
address: string;
|
||||
created_at: string;
|
||||
@ -54,7 +49,7 @@ export const mapCompany = (company: GraphqlQueryCompany): Company => ({
|
||||
}
|
||||
: undefined,
|
||||
creationDate: new Date(company.created_at),
|
||||
opportunities: [{ name: 'Sales Pipeline', icon: '' }],
|
||||
opportunities: [],
|
||||
});
|
||||
|
||||
export const mapGqlCompany = (company: Company): GraphqlMutationCompany => ({
|
||||
|
||||
36
front/src/interfaces/user.interface.test.ts
Normal file
36
front/src/interfaces/user.interface.test.ts
Normal file
File diff suppressed because one or more lines are too long
@ -1,15 +1,39 @@
|
||||
import { GraphqlQueryAccountOwner } from './company.interface';
|
||||
import { Workspace } from './workspace.interface';
|
||||
import {
|
||||
GraphqlQueryWorkspaceMember,
|
||||
WorkspaceMember,
|
||||
} from './workspace.interface';
|
||||
|
||||
export type GraphqlQueryUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
workspace_member?: GraphqlQueryWorkspaceMember;
|
||||
__typename: string;
|
||||
};
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
workspace_member?: {
|
||||
workspace: Workspace;
|
||||
};
|
||||
workspace_member?: WorkspaceMember;
|
||||
}
|
||||
|
||||
export const mapUser = (user: GraphqlQueryAccountOwner): User => ({
|
||||
...user,
|
||||
});
|
||||
export const mapUser = (user: GraphqlQueryUser): User => {
|
||||
const mappedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
} as User;
|
||||
if (user.workspace_member) {
|
||||
mappedUser['workspace_member'] = {
|
||||
workspace: {
|
||||
id: user.workspace_member.workspace.id,
|
||||
displayName: user.workspace_member.workspace.display_name,
|
||||
domainName: user.workspace_member.workspace.domain_name,
|
||||
logo: user.workspace_member.workspace.logo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return mappedUser;
|
||||
};
|
||||
|
||||
@ -1,5 +1,22 @@
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
display_name: string;
|
||||
domainName: string;
|
||||
displayName: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceMember {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
export type GraphqlQueryWorkspace = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
domain_name: string;
|
||||
logo: string;
|
||||
__typename: string;
|
||||
};
|
||||
export type GraphqlQueryWorkspaceMember = {
|
||||
workspace: GraphqlQueryWorkspace;
|
||||
__typename: string;
|
||||
};
|
||||
|
||||
@ -43,7 +43,7 @@ function WorkspaceContainer({ workspace }: OwnProps) {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLogo logo={workspace.logo}></StyledLogo>
|
||||
<StyledName>{workspace?.display_name}</StyledName>
|
||||
<StyledName>{workspace?.displayName}</StyledName>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -9,6 +9,7 @@ export const defaultData: Array<GraphqlQueryCompany> = [
|
||||
id: '91510aa5-ede6-451f-8029-a7fa69e4bad6',
|
||||
email: 'john@example.com',
|
||||
displayName: 'John Doe',
|
||||
__typename: 'User',
|
||||
},
|
||||
employees: 10,
|
||||
address: '1 Infinity Loop, 95014 Cupertino, California',
|
||||
|
||||
@ -96,7 +96,7 @@ export const companiesColumns = [
|
||||
cell: (props) => (
|
||||
<ClickableCell href="#">
|
||||
{props.row.original.opportunities.map((opportunity) => (
|
||||
<PipeChip name={opportunity.name} picture={opportunity.icon} />
|
||||
<PipeChip opportunity={opportunity} />
|
||||
))}
|
||||
</ClickableCell>
|
||||
),
|
||||
|
||||
@ -12,9 +12,13 @@ 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';
|
||||
|
||||
const StyledPeopleContainer = styled.div`
|
||||
display: flex;
|
||||
@ -23,15 +27,22 @@ const StyledPeopleContainer = styled.div`
|
||||
`;
|
||||
|
||||
function People() {
|
||||
const [, setSorts] = useState([] as Array<PeopleSelectedSortType>);
|
||||
const [orderBy, setOrderBy] = useState(defaultOrderBy);
|
||||
const [where, setWhere] = useState<People_Bool_Exp>({});
|
||||
const [filterSearchResults, setSearhInput, setFilterSearch] = useSearch();
|
||||
|
||||
const updateSorts = useCallback((sorts: Array<PeopleSelectedSortType>) => {
|
||||
setSorts(sorts);
|
||||
setOrderBy(sorts.length ? reduceSortsToOrderBy(sorts) : defaultOrderBy);
|
||||
}, []);
|
||||
|
||||
const { data } = usePeopleQuery(orderBy);
|
||||
const updateFilters = useCallback(
|
||||
(filters: Array<SelectedFilterType<People_Bool_Exp>>) => {
|
||||
setWhere(reduceFiltersToWhere(filters));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { data } = usePeopleQuery(orderBy, where);
|
||||
|
||||
return (
|
||||
<WithTopBarContainer title="People" icon={<FaRegUser />}>
|
||||
@ -42,9 +53,15 @@ function People() {
|
||||
columns={peopleColumns}
|
||||
viewName="All People"
|
||||
viewIcon={<FaList />}
|
||||
onSortsUpdate={updateSorts}
|
||||
availableSorts={availableSorts}
|
||||
availableFilters={availableFilters}
|
||||
filterSearchResults={filterSearchResults}
|
||||
onSortsUpdate={updateSorts}
|
||||
onFiltersUpdate={updateFilters}
|
||||
onFilterSearch={(filter, searchValue) => {
|
||||
setSearhInput(searchValue);
|
||||
setFilterSearch(filter);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</StyledPeopleContainer>
|
||||
|
||||
@ -5,6 +5,7 @@ import { lightTheme } from '../../../layout/styles/themes';
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { defaultData } from '../default-data';
|
||||
import { GET_PEOPLE } from '../../../services/people';
|
||||
import { SEARCH_PEOPLE_QUERY } from '../../../services/search/search';
|
||||
|
||||
const component = {
|
||||
title: 'People',
|
||||
@ -19,6 +20,7 @@ const mocks = [
|
||||
query: GET_PEOPLE,
|
||||
variables: {
|
||||
orderBy: [{ created_at: 'desc' }],
|
||||
where: {},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
@ -27,6 +29,19 @@ const mocks = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SEARCH_PEOPLE_QUERY, // TODO this should not be called for empty filters
|
||||
variables: {
|
||||
where: undefined,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
people: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const PeopleDefault = () => (
|
||||
|
||||
@ -21,9 +21,9 @@ export const defaultData: Array<GraphqlQueryPerson> = [
|
||||
{
|
||||
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d',
|
||||
__typename: 'Person',
|
||||
firstname: 'Alexandre',
|
||||
lastname: 'Prot',
|
||||
email: 'alexandre@qonto.com',
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
email: 'john@linkedin.com',
|
||||
company: {
|
||||
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6e',
|
||||
name: 'LinkedIn',
|
||||
@ -38,9 +38,9 @@ export const defaultData: Array<GraphqlQueryPerson> = [
|
||||
{
|
||||
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6f',
|
||||
__typename: 'Person',
|
||||
firstname: 'Alexandre',
|
||||
lastname: 'Prot',
|
||||
email: 'alexandre@qonto.com',
|
||||
firstname: 'Jane',
|
||||
lastname: 'Doe',
|
||||
email: 'jane@sequoiacap.com',
|
||||
company: {
|
||||
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6g',
|
||||
name: 'Sequoia',
|
||||
@ -56,9 +56,9 @@ export const defaultData: Array<GraphqlQueryPerson> = [
|
||||
{
|
||||
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6h',
|
||||
__typename: 'Person',
|
||||
firstname: 'Alexandre',
|
||||
lastname: 'Prot',
|
||||
email: 'alexandre@qonto.com',
|
||||
firstname: 'Janice',
|
||||
lastname: 'Dane',
|
||||
email: 'janice@facebook.com',
|
||||
company: {
|
||||
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6i',
|
||||
name: 'Facebook',
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
FaMapPin,
|
||||
FaPhone,
|
||||
FaStream,
|
||||
FaUser,
|
||||
FaBuilding,
|
||||
} from 'react-icons/fa';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import ClickableCell from '../../components/table/ClickableCell';
|
||||
@ -15,7 +17,7 @@ import Checkbox from '../../components/form/Checkbox';
|
||||
import HorizontalyAlignedContainer from '../../layout/containers/HorizontalyAlignedContainer';
|
||||
import CompanyChip from '../../components/chips/CompanyChip';
|
||||
import PersonChip from '../../components/chips/PersonChip';
|
||||
import { Person } from '../../interfaces/person.interface';
|
||||
import { GraphqlQueryPerson, Person } from '../../interfaces/person.interface';
|
||||
import PipeChip from '../../components/chips/PipeChip';
|
||||
import EditableCell from '../../components/table/EditableCell';
|
||||
import { OrderByFields, updatePerson } from '../../services/people';
|
||||
@ -23,6 +25,12 @@ import {
|
||||
FilterType,
|
||||
SortType,
|
||||
} from '../../components/table/table-header/interface';
|
||||
import { People_Bool_Exp } from '../../generated/graphql';
|
||||
import {
|
||||
SEARCH_COMPANY_QUERY,
|
||||
SEARCH_PEOPLE_QUERY,
|
||||
} from '../../services/search/search';
|
||||
import { GraphqlQueryCompany } from '../../interfaces/company.interface';
|
||||
|
||||
export const availableSorts = [
|
||||
{
|
||||
@ -53,26 +61,74 @@ export const availableFilters = [
|
||||
{
|
||||
key: 'fullname',
|
||||
label: 'People',
|
||||
icon: <FaRegUser />,
|
||||
icon: <FaUser />,
|
||||
whereTemplate: (_operand, { firstname, lastname }) => ({
|
||||
_and: [
|
||||
{ firstname: { _ilike: `${firstname}` } },
|
||||
{ lastname: { _ilike: `${lastname}` } },
|
||||
],
|
||||
}),
|
||||
searchQuery: SEARCH_PEOPLE_QUERY,
|
||||
searchTemplate: (searchInput: string) => ({
|
||||
_or: [
|
||||
{ firstname: { _ilike: `%${searchInput}%` } },
|
||||
{ lastname: { _ilike: `%${searchInput}%` } },
|
||||
],
|
||||
}),
|
||||
searchResultMapper: (person: GraphqlQueryPerson) => ({
|
||||
displayValue: `${person.firstname} ${person.lastname}`,
|
||||
value: { firstname: person.firstname, lastname: person.lastname },
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'company_name',
|
||||
label: 'Company',
|
||||
icon: <FaRegBuilding />,
|
||||
icon: <FaBuilding />,
|
||||
whereTemplate: (_operand, { companyName }) => ({
|
||||
company: { name: { _ilike: `%${companyName}%` } },
|
||||
}),
|
||||
searchQuery: SEARCH_COMPANY_QUERY,
|
||||
searchTemplate: (searchInput: string) => ({
|
||||
name: { _ilike: `%${searchInput}%` },
|
||||
}),
|
||||
searchResultMapper: (company: GraphqlQueryCompany) => ({
|
||||
displayValue: company.name,
|
||||
value: { companyName: company.name },
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
icon: <FaEnvelope />,
|
||||
},
|
||||
{ key: 'phone', label: 'Phone', icon: <FaPhone /> },
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Created at',
|
||||
icon: <FaCalendar />,
|
||||
},
|
||||
{ key: 'city', label: 'City', icon: <FaMapPin /> },
|
||||
] satisfies FilterType[];
|
||||
// {
|
||||
// key: 'email',
|
||||
// label: 'Email',
|
||||
// icon: faEnvelope,
|
||||
// whereTemplate: () => ({ email: { _ilike: '%value%' } }),
|
||||
// searchQuery: GET_PEOPLE,
|
||||
// searchTemplate: { email: { _ilike: '%value%' } },
|
||||
// },
|
||||
// {
|
||||
// key: 'phone',
|
||||
// label: 'Phone',
|
||||
// icon: faPhone,
|
||||
// whereTemplate: () => ({ phone: { _ilike: '%value%' } }),
|
||||
// searchQuery: GET_PEOPLE,
|
||||
// searchTemplate: { phone: { _ilike: '%value%' } },
|
||||
// },
|
||||
// {
|
||||
// key: 'created_at',
|
||||
// label: 'Created at',
|
||||
// icon: faCalendar,
|
||||
// whereTemplate: () => ({ created_at: { _eq: '%value%' } }),
|
||||
// searchQuery: GET_PEOPLE,
|
||||
// searchTemplate: { created_at: { _eq: '%value%' } },
|
||||
// },
|
||||
// {
|
||||
// key: 'city',
|
||||
// label: 'City',
|
||||
// icon: faMapPin,
|
||||
// whereTemplate: () => ({ city: { _ilike: '%value%' } }),
|
||||
// searchQuery: GET_PEOPLE,
|
||||
// searchTemplate: { city: { _ilike: '%value%' } },
|
||||
// },
|
||||
] satisfies FilterType<People_Bool_Exp>[];
|
||||
|
||||
const columnHelper = createColumnHelper<Person>();
|
||||
export const peopleColumns = [
|
||||
@ -151,10 +207,7 @@ export const peopleColumns = [
|
||||
header: () => <ColumnHead viewName="Pipe" viewIcon={<FaStream />} />,
|
||||
cell: (props) => (
|
||||
<ClickableCell href="#">
|
||||
<PipeChip
|
||||
name={props.row.original.pipe.name}
|
||||
picture={props.row.original.pipe.icon}
|
||||
/>
|
||||
<PipeChip opportunity={props.row.original.pipe} />
|
||||
</ClickableCell>
|
||||
),
|
||||
}),
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { QueryResult, gql, useQuery } from '@apollo/client';
|
||||
import { GraphqlQueryPerson } from '../../interfaces/person.interface';
|
||||
import { Order_By, People_Order_By } from '../../generated/graphql';
|
||||
import { SelectedSortType } from '../../components/table/table-header/interface';
|
||||
import {
|
||||
Order_By,
|
||||
People_Bool_Exp,
|
||||
People_Order_By,
|
||||
} from '../../generated/graphql';
|
||||
import {
|
||||
SelectedFilterType,
|
||||
SelectedSortType,
|
||||
} from '../../components/table/table-header/interface';
|
||||
|
||||
export type OrderByFields = keyof People_Order_By | 'fullname' | 'company_name';
|
||||
|
||||
@ -11,6 +18,16 @@ 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[] => {
|
||||
@ -31,8 +48,12 @@ export const reduceSortsToOrderBy = (
|
||||
};
|
||||
|
||||
export const GET_PEOPLE = gql`
|
||||
query GetPeople($orderBy: [people_order_by!]) {
|
||||
people(order_by: $orderBy) {
|
||||
query GetPeople(
|
||||
$orderBy: [people_order_by!]
|
||||
$where: people_bool_exp
|
||||
$limit: Int
|
||||
) {
|
||||
people(order_by: $orderBy, where: $where, limit: $limit) {
|
||||
id
|
||||
phone
|
||||
email
|
||||
@ -51,9 +72,10 @@ export const GET_PEOPLE = gql`
|
||||
|
||||
export function usePeopleQuery(
|
||||
orderBy: People_Order_By[],
|
||||
where: People_Bool_Exp,
|
||||
): QueryResult<{ people: GraphqlQueryPerson[] }> {
|
||||
return useQuery<{ people: GraphqlQueryPerson[] }>(GET_PEOPLE, {
|
||||
variables: { orderBy },
|
||||
variables: { orderBy, where },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
105
front/src/services/search/search.ts
Normal file
105
front/src/services/search/search.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { gql, useQuery } from '@apollo/client';
|
||||
import { People_Bool_Exp } from '../../generated/graphql';
|
||||
import {} from '../../interfaces/company.interface';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FilterType } from '../../components/table/table-header/interface';
|
||||
|
||||
export const SEARCH_PEOPLE_QUERY = gql`
|
||||
query SearchQuery($where: people_bool_exp, $limit: Int) {
|
||||
searchResults: people(where: $where, limit: $limit) {
|
||||
id
|
||||
phone
|
||||
email
|
||||
city
|
||||
firstname
|
||||
lastname
|
||||
created_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const EMPTY_QUERY = gql`
|
||||
query EmptyQuery {
|
||||
_
|
||||
}
|
||||
`;
|
||||
|
||||
export const SEARCH_COMPANY_QUERY = gql`
|
||||
query SearchQuery($where: companies_bool_exp, $limit: Int) {
|
||||
searchResults: companies(where: $where, limit: $limit) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const debounce = <FuncArgs extends any[]>(
|
||||
func: (...args: FuncArgs) => void,
|
||||
delay: number,
|
||||
) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
return (...args: FuncArgs) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
func(...args);
|
||||
}, delay);
|
||||
};
|
||||
};
|
||||
|
||||
export const useSearch = (): [
|
||||
{ results: { displayValue: string; value: any }[]; loading: boolean },
|
||||
React.Dispatch<React.SetStateAction<string>>,
|
||||
React.Dispatch<React.SetStateAction<FilterType<People_Bool_Exp> | null>>,
|
||||
] => {
|
||||
const [filter, setFilter] = useState<FilterType<People_Bool_Exp> | null>(
|
||||
null,
|
||||
);
|
||||
const [searchInput, setSearchInput] = useState<string>('');
|
||||
|
||||
const debouncedsetSearchInput = useMemo(
|
||||
() => debounce(setSearchInput, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
const where = useMemo(() => {
|
||||
return (
|
||||
filter && filter.searchTemplate && filter.searchTemplate(searchInput)
|
||||
);
|
||||
}, [filter, searchInput]);
|
||||
|
||||
const searchFilterQueryResults = useQuery(
|
||||
filter?.searchQuery || EMPTY_QUERY,
|
||||
{
|
||||
variables: {
|
||||
where,
|
||||
},
|
||||
skip: !filter,
|
||||
},
|
||||
);
|
||||
|
||||
const searchFilterResults = useMemo<{
|
||||
results: { displayValue: string; value: any }[];
|
||||
loading: boolean;
|
||||
}>(() => {
|
||||
if (filter == null) {
|
||||
return {
|
||||
loading: false,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
if (searchFilterQueryResults.loading) {
|
||||
return {
|
||||
loading: true,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
loading: false,
|
||||
results: searchFilterQueryResults.data.searchResults.map(
|
||||
filter.searchResultMapper,
|
||||
),
|
||||
};
|
||||
}, [filter, searchFilterQueryResults]);
|
||||
|
||||
return [searchFilterResults, debouncedsetSearchInput, setFilter];
|
||||
};
|
||||
@ -1,11 +0,0 @@
|
||||
describe('Get Current user', () => {
|
||||
it('should return a parsed user if api returns it', () => {
|
||||
// TBD
|
||||
});
|
||||
|
||||
it('should not return a user if api does not return it', () => {
|
||||
// TBD
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
@ -1,5 +1,5 @@
|
||||
import { QueryResult, gql, useQuery } from '@apollo/client';
|
||||
import { GraphqlQueryAccountOwner } from '../../interfaces/company.interface';
|
||||
import { GraphqlQueryUser } from '../../interfaces/user.interface';
|
||||
|
||||
export const GET_CURRENT_USER = gql`
|
||||
query GetCurrentUser {
|
||||
@ -20,7 +20,7 @@ export const GET_CURRENT_USER = gql`
|
||||
`;
|
||||
|
||||
export function useGetCurrentUserQuery(): QueryResult<{
|
||||
users: GraphqlQueryAccountOwner[];
|
||||
users: GraphqlQueryUser[];
|
||||
}> {
|
||||
return useQuery<{ users: GraphqlQueryAccountOwner[] }>(GET_CURRENT_USER);
|
||||
return useQuery<{ users: GraphqlQueryUser[] }>(GET_CURRENT_USER);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user