Refactor/filters (#498)

* wip

* - Added scopes on useHotkeys
- Use new EditableCellV2
- Implemented Recoil Scoped State with specific context
- Implemented soft focus position
- Factorized open/close editable cell
- Removed editable relation old components
- Broke down entity table into multiple components
- Added Recoil Scope by CellContext
- Added Recoil Scope by RowContext

* First working version

* Use a new EditableCellSoftFocusMode

* Fixes

* wip

* wip

* wip

* Use company filters

* Refactored FilterDropdown into multiple components

* Refactored entity search select in dropdown

* Renamed states

* Fixed people filters

* Removed unused code

* Cleaned states

* Cleaned state

* Better naming

* fixed rebase

* Fix

* Fixed stories and mocked data and displayName bug

* Fixed cancel sort

* Fixed naming

* Fixed dropdown height

* Fix

* Fixed lint
This commit is contained in:
Lucas Bordeau
2023-07-04 15:54:58 +02:00
committed by GitHub
parent 580e6024d0
commit 820ef184d3
78 changed files with 1631 additions and 1229 deletions

View File

@ -18,6 +18,8 @@ export const VERIFY = gql`
id
email
displayName
firstName
lastName
workspaceMember {
id
workspace {

View File

@ -24,6 +24,8 @@ const mockComment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'> = {
author: {
id: v4(),
displayName: mockUser.displayName ?? '',
firstName: mockUser.firstName ?? '',
lastName: mockUser.lastName ?? '',
avatarUrl: mockUser.avatarUrl,
},
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',
@ -37,6 +39,8 @@ const mockCommentWithLongName: Pick<
author: {
id: v4(),
displayName: mockUser.displayName + ' with a very long suffix' ?? '',
firstName: mockUser.firstName ?? '',
lastName: mockUser.lastName ?? '',
avatarUrl: mockUser.avatarUrl,
},
createdAt: DateTime.now().minus({ hours: 2 }).toISO() ?? '',

View File

@ -23,6 +23,8 @@ export const CREATE_COMMENT = gql`
author {
id
displayName
firstName
lastName
avatarUrl
}
commentThreadId

View File

@ -22,6 +22,8 @@ export const GET_COMMENT_THREADS_BY_TARGETS = gql`
author {
id
displayName
firstName
lastName
avatarUrl
}
}
@ -46,6 +48,8 @@ export const GET_COMMENT_THREAD = gql`
author {
id
displayName
firstName
lastName
avatarUrl
}
}

View File

@ -41,7 +41,7 @@ export function CompanyAccountOwnerPicker({ company }: OwnProps) {
avatarType: 'rounded',
}),
orderByField: 'displayName',
searchOnFields: ['displayName'],
searchOnFields: ['firstName', 'lastName'],
});
async function handleEntitySelected(selectedUser: UserForSelect) {

View File

@ -0,0 +1,43 @@
import { filterDropdownSearchInputScopedState } from '@/filters-and-sorts/states/filterDropdownSearchInputScopedState';
import { filterDropdownSelectedEntityIdScopedState } from '@/filters-and-sorts/states/filterDropdownSelectedEntityIdScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/recoil-scope/hooks/useRecoilScopedValue';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { Entity } from '@/relation-picker/types/EntityTypeForSelect';
import { FilterDropdownEntitySearchSelect } from '@/ui/components/table/table-header/FilterDropdownEntitySearchSelect';
import { TableContext } from '@/ui/tables/states/TableContext';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import { useSearchCompanyQuery } from '~/generated/graphql';
export function FilterDropdownCompanySearchSelect() {
const filterDropdownSearchInput = useRecoilScopedValue(
filterDropdownSearchInputScopedState,
TableContext,
);
const [filterDropdownSelectedEntityId] = useRecoilScopedState(
filterDropdownSelectedEntityIdScopedState,
TableContext,
);
const usersForSelect = useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
searchOnFields: ['name'],
orderByField: 'name',
selectedIds: filterDropdownSelectedEntityId
? [filterDropdownSelectedEntityId]
: [],
mappingFunction: (company) => ({
id: company.id,
entityType: Entity.User,
name: `${company.name}`,
avatarType: 'squared',
avatarUrl: getLogoUrlFromDomainName(company.domainName),
}),
searchFilter: filterDropdownSearchInput,
});
return (
<FilterDropdownEntitySearchSelect entitiesForSelect={usersForSelect} />
);
}

View File

@ -27,6 +27,8 @@ export const GET_COMPANIES = gql`
id
email
displayName
firstName
lastName
}
}
}

View File

@ -25,6 +25,8 @@ export const UPDATE_COMPANY = gql`
id
email
displayName
firstName
lastName
}
address
createdAt

View File

@ -1,20 +1,7 @@
import { SortOrder as Order_By } from '~/generated/graphql';
import {
FilterWhereType,
SelectedFilterType,
} from './interfaces/filters/interface';
import { SelectedSortType } from './interfaces/sorts/interface';
export const reduceFiltersToWhere = <WhereTemplateType extends FilterWhereType>(
filters: Array<SelectedFilterType<WhereTemplateType>>,
): Record<string, any> => {
const where = filters.reduce((acc, filter) => {
return { ...acc, ...filter.operand.whereTemplate(filter.value) };
}, {} as Record<string, any>);
return where;
};
const mapOrderToOrder_By = (order: string) => {
if (order === 'asc') return Order_By.Asc;
return Order_By.Desc;

View File

@ -0,0 +1,26 @@
import { useMemo } from 'react';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
import { activeTableFiltersScopedState } from '../states/activeTableFiltersScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '../states/tableFilterDefinitionUsedInDropdownScopedState';
export function useActiveTableFilterCurrentlyEditedInDropdown() {
const [activeTableFilters] = useRecoilScopedState(
activeTableFiltersScopedState,
TableContext,
);
const [tableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
return useMemo(() => {
return activeTableFilters.find(
(activeTableFilter) =>
activeTableFilter.field === tableFilterDefinitionUsedInDropdown?.field,
);
}, [tableFilterDefinitionUsedInDropdown, activeTableFilters]);
}

View File

@ -0,0 +1,19 @@
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
import { activeTableFiltersScopedState } from '../states/activeTableFiltersScopedState';
export function useRemoveActiveTableFilter() {
const [, setActiveTableFilters] = useRecoilScopedState(
activeTableFiltersScopedState,
TableContext,
);
return function removeActiveTableFilter(filterField: string) {
setActiveTableFilters((activeTableFilters) => {
return activeTableFilters.filter((activeTableFilter) => {
return activeTableFilter.field !== filterField;
});
});
};
}

View File

@ -0,0 +1,33 @@
import { produce } from 'immer';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
import { activeTableFiltersScopedState } from '../states/activeTableFiltersScopedState';
import { ActiveTableFilter } from '../types/ActiveTableFilter';
export function useUpsertActiveTableFilter() {
const [, setActiveTableFilters] = useRecoilScopedState(
activeTableFiltersScopedState,
TableContext,
);
return function upsertActiveTableFilter(
activeTableFilterToUpsert: ActiveTableFilter,
) {
setActiveTableFilters((activeTableFilters) => {
return produce(activeTableFilters, (activeTableFiltersDraft) => {
const index = activeTableFiltersDraft.findIndex(
(activeTableFilter) =>
activeTableFilter.field === activeTableFilterToUpsert.field,
);
if (index === -1) {
activeTableFiltersDraft.push(activeTableFilterToUpsert);
} else {
activeTableFiltersDraft[index] = activeTableFilterToUpsert;
}
});
});
};
}

View File

@ -1,64 +0,0 @@
import { ReactNode } from 'react';
import { SearchConfigType } from '@/search/interfaces/interface';
export type FilterableFieldsType = any;
export type FilterWhereRelationType = any;
export type FilterWhereType = FilterWhereRelationType | string | unknown;
export type FilterConfigType<WhereType extends FilterWhereType = unknown> = {
key: string;
label: string;
icon: ReactNode;
type: WhereType extends unknown
? 'relation' | 'text' | 'date'
: WhereType extends any
? 'relation'
: WhereType extends string
? 'text' | 'date'
: never;
operands: FilterOperandType<WhereType>[];
} & (WhereType extends unknown
? { searchConfig?: SearchConfigType }
: WhereType extends any
? { searchConfig: SearchConfigType }
: WhereType extends string
? object
: never) &
(WhereType extends unknown
? { selectedValueRender?: (selected: any) => string }
: WhereType extends any
? { selectedValueRender: (selected: WhereType) => string }
: WhereType extends string
? object
: never);
export type FilterOperandType<WhereType extends FilterWhereType = unknown> =
WhereType extends unknown
? any
: WhereType extends FilterWhereRelationType
? FilterOperandRelationType<WhereType>
: WhereType extends string
? FilterOperandFieldType
: never;
type FilterOperandRelationType<WhereType extends FilterWhereType> = {
label: 'Is' | 'Is not';
id: 'is' | 'is_not';
whereTemplate: (value: WhereType) => any;
};
type FilterOperandFieldType = {
label: 'Contains' | "Doesn't contain" | 'Greater than' | 'Less than';
id: 'like' | 'not_like' | 'greater_than' | 'less_than';
whereTemplate: (value: string) => any;
};
export type SelectedFilterType<WhereType> = {
key: string;
value: WhereType;
displayValue: string;
label: string;
icon: ReactNode;
operand: FilterOperandType<WhereType>;
};

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { ActiveTableFilter } from '@/filters-and-sorts/types/ActiveTableFilter';
export const activeTableFiltersScopedState = atomFamily<
ActiveTableFilter[],
string
>({
key: 'activeTableFiltersScopedState',
default: [],
});

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { TableFilterDefinition } from '../types/TableFilterDefinition';
export const availableTableFiltersScopedState = atomFamily<
TableFilterDefinition[],
string
>({
key: 'availableTableFiltersScopedState',
default: [],
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const filterDropdownSearchInputScopedState = atomFamily<string, string>({
key: 'filterDropdownSearchInputScopedState',
default: '',
});

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const filterDropdownSelectedEntityIdScopedState = atomFamily<
string | null,
string
>({
key: 'filterDropdownSelectedEntityIdScopedState',
default: null,
});

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const isFilterDropdownOperandSelectUnfoldedScopedState = atomFamily<
boolean,
string
>({
key: 'isFilterDropdownOperandSelectUnfoldedScopedState',
default: false,
});

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { TableFilterOperand } from '../types/TableFilterOperand';
export const selectedOperandInDropdownScopedState = atomFamily<
TableFilterOperand | null,
string
>({
key: 'selectedOperandInDropdownScopedState',
default: null,
});

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { TableFilterDefinition } from '../types/TableFilterDefinition';
export const tableFilterDefinitionUsedInDropdownScopedState = atomFamily<
TableFilterDefinition | null,
string
>({
key: 'tableFilterDefinitionUsedInDropdownScopedState',
default: null,
});

View File

@ -0,0 +1,10 @@
import { TableFilterOperand } from './TableFilterOperand';
import { TableFilterType } from './TableFilterType';
export type ActiveTableFilter = {
field: string;
type: TableFilterType;
value: string;
displayValue: string;
operand: TableFilterOperand;
};

View File

@ -0,0 +1,4 @@
export type FilterSearchResult = {
id: string;
label: string;
};

View File

@ -0,0 +1,9 @@
import { TableFilterType } from './TableFilterType';
export type TableFilterDefinition = {
field: string;
label: string;
icon: JSX.Element;
type: TableFilterType;
entitySelectComponent?: JSX.Element;
};

View File

@ -0,0 +1,5 @@
import { TableFilterDefinition } from './TableFilterDefinition';
export type TableFilterDefinitionByEntity<T> = TableFilterDefinition & {
field: keyof T;
};

View File

@ -0,0 +1,7 @@
export type TableFilterOperand =
| 'contains'
| 'does-not-contain'
| 'greater-than'
| 'less-than'
| 'is'
| 'is-not';

View File

@ -0,0 +1 @@
export type TableFilterType = 'text' | 'date' | 'entity' | 'number';

View File

@ -0,0 +1,22 @@
import { TableFilterOperand } from '../types/TableFilterOperand';
export function getOperandLabel(
operand: TableFilterOperand | null | undefined,
) {
switch (operand) {
case 'contains':
return 'Contains';
case 'does-not-contain':
return "Does'nt contain";
case 'greater-than':
return 'Greater than';
case 'less-than':
return 'Less than';
case 'is':
return 'Is';
case 'is-not':
return 'Is not';
default:
return '';
}
}

View File

@ -0,0 +1,18 @@
import { TableFilterOperand } from '../types/TableFilterOperand';
import { TableFilterType } from '../types/TableFilterType';
export function getOperandsForFilterType(
filterType: TableFilterType | null | undefined,
): TableFilterOperand[] {
switch (filterType) {
case 'text':
return ['contains', 'does-not-contain'];
case 'number':
case 'date':
return ['greater-than', 'less-than'];
case 'entity':
return ['is', 'is-not'];
default:
return [];
}
}

View File

@ -0,0 +1,86 @@
import { ActiveTableFilter } from '../types/ActiveTableFilter';
export function turnFilterIntoWhereClause(filter: ActiveTableFilter) {
switch (filter.type) {
case 'text':
switch (filter.operand) {
case 'contains':
return {
[filter.field]: {
contains: filter.value,
},
};
case 'does-not-contain':
return {
[filter.field]: {
not: {
contains: filter.value,
},
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.type} filter`,
);
}
case 'number':
switch (filter.operand) {
case 'greater-than':
return {
[filter.field]: {
gte: parseFloat(filter.value),
},
};
case 'less-than':
return {
[filter.field]: {
lte: parseFloat(filter.value),
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.type} filter`,
);
}
case 'date':
switch (filter.operand) {
case 'greater-than':
return {
[filter.field]: {
gte: filter.value,
},
};
case 'less-than':
return {
[filter.field]: {
lte: filter.value,
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.type} filter`,
);
}
case 'entity':
switch (filter.operand) {
case 'is':
return {
[filter.field]: {
equals: filter.value,
},
};
case 'is-not':
return {
[filter.field]: {
not: { equals: filter.value },
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.type} filter`,
);
}
default:
throw new Error('Unknown filter type');
}
}

View File

@ -1,16 +1,19 @@
import { useContext } from 'react';
import { Context, useContext } from 'react';
import { RecoilState, useRecoilValue } from 'recoil';
import { RecoilScopeContext } from '../states/RecoilScopeContext';
export function useRecoilScopedValue<T>(
recoilState: (param: string) => RecoilState<T>,
SpecificContext?: Context<string | null>,
) {
const recoilScopeId = useContext(RecoilScopeContext);
const recoilScopeId = useContext(SpecificContext ?? RecoilScopeContext);
if (!recoilScopeId)
throw new Error(
`Using a scoped atom without a RecoilScope : ${recoilState('').key}`,
`Using a scoped atom without a RecoilScope : ${
recoilState('').key
}, verify that you are using a RecoilScope with a specific context if you intended to do so.`,
);
return useRecoilValue<T>(recoilState(recoilScopeId));

View File

@ -1,20 +1,17 @@
import { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTheme } from '@emotion/react';
import { IconPlus } from '@tabler/icons-react';
import { EntityForSelect } from '@/relation-picker/types/EntityForSelect';
import { DropdownMenu } from '@/ui/components/menu/DropdownMenu';
import { DropdownMenuButton } from '@/ui/components/menu/DropdownMenuButton';
import { DropdownMenuItem } from '@/ui/components/menu/DropdownMenuItem';
import { DropdownMenuItemContainer } from '@/ui/components/menu/DropdownMenuItemContainer';
import { DropdownMenuSearch } from '@/ui/components/menu/DropdownMenuSearch';
import { DropdownMenuSelectableItem } from '@/ui/components/menu/DropdownMenuSelectableItem';
import { DropdownMenuSeparator } from '@/ui/components/menu/DropdownMenuSeparator';
import { Avatar } from '@/users/components/Avatar';
import { isDefined } from '@/utils/type-guards/isDefined';
import { useEntitySelectLogic } from '../hooks/useEntitySelectLogic';
import { useEntitySelectSearch } from '../hooks/useEntitySelectSearch';
import { SingleEntitySelectBase } from './SingleEntitySelectBase';
export type EntitiesForSingleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
@ -35,28 +32,8 @@ export function SingleEntitySelect<
onEntitySelected: (entity: CustomEntityForSelect) => void;
}) {
const theme = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const entitiesInDropdown = isDefined(entities.selectedEntity)
? [entities.selectedEntity, ...(entities.entitiesToSelect ?? [])]
: entities.entitiesToSelect ?? [];
const { hoveredIndex, searchFilter, handleSearchFilterChange } =
useEntitySelectLogic({
entities: entitiesInDropdown,
containerRef,
});
useHotkeys(
'enter',
() => {
onEntitySelected(entitiesInDropdown[hoveredIndex]);
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[entitiesInDropdown, hoveredIndex, onEntitySelected],
);
const { searchFilter, handleSearchFilterChange } = useEntitySelectSearch();
const showCreateButton = isDefined(onCreate) && searchFilter !== '';
@ -70,7 +47,7 @@ export function SingleEntitySelect<
<DropdownMenuSeparator />
{showCreateButton && (
<>
<DropdownMenuItemContainer>
<DropdownMenuItemContainer style={{ maxHeight: 180 }}>
<DropdownMenuButton onClick={onCreate}>
<IconPlus size={theme.icon.size.md} />
Create new
@ -79,27 +56,10 @@ export function SingleEntitySelect<
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItemContainer ref={containerRef}>
{entitiesInDropdown?.map((entity, index) => (
<DropdownMenuSelectableItem
key={entity.id}
selected={entities.selectedEntity?.id === entity.id}
hovered={hoveredIndex === index}
onClick={() => onEntitySelected(entity)}
>
<Avatar
avatarUrl={entity.avatarUrl}
placeholder={entity.name}
size={16}
type={entity.avatarType ?? 'rounded'}
/>
{entity.name}
</DropdownMenuSelectableItem>
))}
{entitiesInDropdown?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemContainer>
<SingleEntitySelectBase
entities={entities}
onEntitySelected={onEntitySelected}
/>
</DropdownMenu>
);
}

View File

@ -0,0 +1,74 @@
import { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { EntityForSelect } from '@/relation-picker/types/EntityForSelect';
import { DropdownMenuItem } from '@/ui/components/menu/DropdownMenuItem';
import { DropdownMenuItemContainer } from '@/ui/components/menu/DropdownMenuItemContainer';
import { DropdownMenuSelectableItem } from '@/ui/components/menu/DropdownMenuSelectableItem';
import { Avatar } from '@/users/components/Avatar';
import { isDefined } from '@/utils/type-guards/isDefined';
import { useEntitySelectScroll } from '../hooks/useEntitySelectScroll';
export type EntitiesForSingleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
> = {
selectedEntity: CustomEntityForSelect;
entitiesToSelect: CustomEntityForSelect[];
};
export function SingleEntitySelectBase<
CustomEntityForSelect extends EntityForSelect,
>({
entities,
onEntitySelected,
}: {
entities: EntitiesForSingleEntitySelect<CustomEntityForSelect>;
onEntitySelected: (entity: CustomEntityForSelect) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const entitiesInDropdown = isDefined(entities.selectedEntity)
? [entities.selectedEntity, ...(entities.entitiesToSelect ?? [])]
: entities.entitiesToSelect ?? [];
const { hoveredIndex } = useEntitySelectScroll({
entities: entitiesInDropdown,
containerRef,
});
useHotkeys(
'enter',
() => {
onEntitySelected(entitiesInDropdown[hoveredIndex]);
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[entitiesInDropdown, hoveredIndex, onEntitySelected],
);
return (
<DropdownMenuItemContainer ref={containerRef}>
{entitiesInDropdown?.map((entity, index) => (
<DropdownMenuSelectableItem
key={entity.id}
selected={entities.selectedEntity?.id === entity.id}
hovered={hoveredIndex === index}
onClick={() => onEntitySelected(entity)}
>
<Avatar
avatarUrl={entity.avatarUrl}
placeholder={entity.name}
size={16}
type={entity.avatarType ?? 'rounded'}
/>
{entity.name}
</DropdownMenuSelectableItem>
))}
{entitiesInDropdown?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemContainer>
);
}

View File

@ -1,14 +1,12 @@
import { useState } from 'react';
import { debounce } from 'lodash';
import scrollIntoView from 'scroll-into-view';
import { useUpDownHotkeys } from '@/hotkeys/hooks/useUpDownHotkeys';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState';
import { relationPickerHoverIndexScopedState } from '../states/relationPickerHoverIndexScopedState';
import { EntityForSelect } from '../types/EntityForSelect';
export function useEntitySelectLogic<
export function useEntitySelectScroll<
CustomEntityForSelect extends EntityForSelect,
>({
containerRef,
@ -17,23 +15,10 @@ export function useEntitySelectLogic<
entities: CustomEntityForSelect[];
containerRef: React.RefObject<HTMLDivElement>;
}) {
const [hoveredIndex, setHoveredIndex] = useState(0);
const [searchFilter, setSearchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
const [hoveredIndex, setHoveredIndex] = useRecoilScopedState(
relationPickerHoverIndexScopedState,
);
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
function handleSearchFilterChange(
event: React.ChangeEvent<HTMLInputElement>,
) {
debouncedSetSearchFilter(event.currentTarget.value);
setHoveredIndex(0);
}
useUpDownHotkeys(
() => {
setHoveredIndex((prevSelectedIndex) =>
@ -82,7 +67,5 @@ export function useEntitySelectLogic<
return {
hoveredIndex,
searchFilter,
handleSearchFilterChange,
};
}

View File

@ -0,0 +1,32 @@
import { debounce } from 'lodash';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { relationPickerHoverIndexScopedState } from '../states/relationPickerHoverIndexScopedState';
import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState';
export function useEntitySelectSearch() {
const [, setHoveredIndex] = useRecoilScopedState(
relationPickerHoverIndexScopedState,
);
const [searchFilter, setSearchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
function handleSearchFilterChange(
event: React.ChangeEvent<HTMLInputElement>,
) {
debouncedSetSearchFilter(event.currentTarget.value);
setHoveredIndex(0);
}
return {
searchFilter,
handleSearchFilterChange,
};
}

View File

@ -24,6 +24,8 @@ type ExtractEntityTypeFromQueryResponse<T> = T extends {
const DEFAULT_SEARCH_REQUEST_LIMIT = 10;
// TODO: use this for all search queries, because we need selectedEntities and entitiesToSelect each time we want to search
// Filtered entities to select are
export function useFilteredSearchEntityQuery<
EntityType extends ExtractEntityTypeFromQueryResponse<QueryResponseForExtract> & {
id: string;

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const relationPickerHoverIndexScopedState = atomFamily<number, string>({
key: 'relationPickerHoverIndexScopedState',
default: 0,
});

View File

@ -1,9 +1,4 @@
import { useMemo, useState } from 'react';
import { gql, useQuery } from '@apollo/client';
import { debounce } from '@/utils/debounce';
import { SearchConfigType } from '../interfaces/interface';
import { gql } from '@apollo/client';
export const SEARCH_PEOPLE_QUERY = gql`
query SearchPeople(
@ -41,6 +36,8 @@ export const SEARCH_USER_QUERY = gql`
id
email
displayName
firstName
lastName
}
}
`;
@ -70,80 +67,3 @@ export const SEARCH_COMPANY_QUERY = gql`
}
}
`;
export type SearchResultsType<T> = {
results: {
render: (value: T) => string;
value: T;
}[];
loading: boolean;
};
type SearchArgs = {
currentSelectedId?: string | null;
};
export const useSearch = <T>(
searchArgs?: SearchArgs,
): [
SearchResultsType<T>,
React.Dispatch<React.SetStateAction<string>>,
React.Dispatch<React.SetStateAction<SearchConfigType | null>>,
string,
] => {
const [searchConfig, setSearchConfig] = useState<SearchConfigType | null>(
null,
);
const [searchInput, setSearchInput] = useState<string>('');
const debouncedsetSearchInput = useMemo(
() => debounce(setSearchInput, 50),
[],
);
const where = useMemo(() => {
return (
searchConfig &&
searchConfig.template &&
searchConfig.template(
searchInput,
searchArgs?.currentSelectedId ?? undefined,
)
);
}, [searchConfig, searchInput, searchArgs]);
const searchQueryResults = useQuery(searchConfig?.query || EMPTY_QUERY, {
variables: {
where,
limit: 5,
},
skip: !searchConfig,
});
const searchResults = useMemo<{
results: { render: (value: T) => string; value: any }[];
loading: boolean;
}>(() => {
if (searchConfig == null) {
return {
loading: false,
results: [],
};
}
if (searchQueryResults.loading) {
return {
loading: true,
results: [],
};
}
return {
loading: false,
// TODO: add proper typing
results: searchQueryResults?.data?.searchResults?.map(
searchConfig.resultMapper,
),
};
}, [searchConfig, searchQueryResults]);
return [searchResults, debouncedsetSearchInput, setSearchConfig, searchInput];
};

View File

@ -8,7 +8,7 @@ import { hoverBackground } from '@/ui/themes/effects';
import { DropdownMenuButton } from './DropdownMenuButton';
type Props = {
selected: boolean;
selected?: boolean;
onClick: () => void;
hovered?: boolean;
};

View File

@ -8,10 +8,6 @@ import {
} from '@tanstack/react-table';
import { useRecoilState } from 'recoil';
import {
FilterConfigType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import {
SelectedSortType,
SortType,
@ -30,9 +26,7 @@ type OwnProps<TData extends { id: string }, SortField> = {
viewName: string;
viewIcon?: React.ReactNode;
availableSorts?: Array<SortType<SortField>>;
availableFilters?: FilterConfigType<TData>[];
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onFiltersUpdate?: (filters: Array<SelectedFilterType<TData>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
};
@ -107,9 +101,7 @@ export function EntityTable<TData extends { id: string }, SortField>({
viewName,
viewIcon,
availableSorts,
availableFilters,
onSortsUpdate,
onFiltersUpdate,
}: OwnProps<TData, SortField>) {
const [currentRowSelection, setCurrentRowSelection] = useRecoilState(
currentRowSelectionState,
@ -133,9 +125,7 @@ export function EntityTable<TData extends { id: string }, SortField>({
viewName={viewName}
viewIcon={viewIcon}
availableSorts={availableSorts}
availableFilters={availableFilters}
onSortsUpdate={onSortsUpdate}
onFiltersUpdate={onFiltersUpdate}
/>
<StyledTableScrollableContainer>
<StyledTable>

View File

@ -1,12 +1,16 @@
import { TableFilterDefinition } from '@/filters-and-sorts/types/TableFilterDefinition';
import { useInitializeEntityTable } from '@/ui/tables/hooks/useInitializeEntityTable';
import { useInitializeEntityTableFilters } from '@/ui/tables/hooks/useInitializeEntityTableFilters';
import { useMapKeyboardToSoftFocus } from '@/ui/tables/hooks/useMapKeyboardToSoftFocus';
export function HooksEntityTable({
numberOfColumns,
numberOfRows,
availableTableFilters,
}: {
numberOfColumns: number;
numberOfRows: number;
availableTableFilters: TableFilterDefinition[];
}) {
useMapKeyboardToSoftFocus();
@ -15,5 +19,9 @@ export function HooksEntityTable({
numberOfRows,
});
useInitializeEntityTableFilters({
availableTableFilters,
});
return <></>;
}

View File

@ -1,227 +1,68 @@
import { ChangeEvent, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { useCallback, useState } from 'react';
import {
FilterableFieldsType,
FilterConfigType,
FilterOperandType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import { captureHotkeyTypeInFocusState } from '@/hotkeys/states/captureHotkeyTypeInFocusState';
import { SearchResultsType, useSearch } from '@/search/services/search';
import { humanReadableDate } from '@/utils/utils';
import DatePicker from '../../form/DatePicker';
import { DropdownMenuItemContainer } from '../../menu/DropdownMenuItemContainer';
import { DropdownMenuSelectableItem } from '../../menu/DropdownMenuSelectableItem';
import { DropdownMenuSeparator } from '../../menu/DropdownMenuSeparator';
import { activeTableFiltersScopedState } from '@/filters-and-sorts/states/activeTableFiltersScopedState';
import { filterDropdownSearchInputScopedState } from '@/filters-and-sorts/states/filterDropdownSearchInputScopedState';
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '@/filters-and-sorts/states/isFilterDropdownOperandSelectUnfoldedScopedState';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
import DropdownButton from './DropdownButton';
import { FilterDropdownDateSearchInput } from './FilterDropdownDateSearchInput';
import { FilterDropdownEntitySearchInput } from './FilterDropdownEntitySearchInput';
import { FilterDropdownEntitySelect } from './FilterDropdownEntitySelect';
import { FilterDropdownFilterSelect } from './FilterDropdownFilterSelect';
import { FilterDropdownNumberSearchInput } from './FilterDropdownNumberSearchInput';
import { FilterDropdownOperandButton } from './FilterDropdownOperandButton';
import { FilterDropdownOperandSelect } from './FilterDropdownOperandSelect';
import { FilterDropdownTextSearchInput } from './FilterDropdownTextSearchInput';
type OwnProps<TData extends FilterableFieldsType> = {
isFilterSelected: boolean;
availableFilters: FilterConfigType<TData>[];
onFilterSelect: (filter: SelectedFilterType<TData>) => void;
onFilterRemove: (filterId: SelectedFilterType<TData>['key']) => void;
};
export const FilterDropdownButton = <TData extends FilterableFieldsType>({
availableFilters,
onFilterSelect,
isFilterSelected,
onFilterRemove,
}: OwnProps<TData>) => {
export function FilterDropdownButton() {
const [isUnfolded, setIsUnfolded] = useState(false);
const [, setCaptureHotkeyTypeInFocus] = useRecoilState(
captureHotkeyTypeInFocusState,
const [
isFilterDropdownOperandSelectUnfolded,
setIsFilterDropdownOperandSelectUnfolded,
] = useRecoilScopedState(
isFilterDropdownOperandSelectUnfoldedScopedState,
TableContext,
);
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
const [
tableFilterDefinitionUsedInDropdown,
setTableFilterDefinitionUsedInDropdown,
] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
const [isOperandSelectionUnfolded, setIsOperandSelectionUnfolded] =
useState(false);
const [, setFilterDropdownSearchInput] = useRecoilScopedState(
filterDropdownSearchInputScopedState,
TableContext,
);
const [selectedFilter, setSelectedFilter] = useState<
FilterConfigType<TData> | undefined
>(undefined);
const [activeTableFilters] = useRecoilScopedState(
activeTableFiltersScopedState,
TableContext,
);
const [selectedFilterOperand, setSelectedFilterOperand] = useState<
FilterOperandType<TData> | undefined
>(undefined);
const [filterSearchResults, setSearchInput, setFilterSearch] =
useSearch<TData>({ currentSelectedId: selectedEntityId });
const [selectedOperandInDropdown, setSelectedOperandInDropdown] =
useRecoilScopedState(selectedOperandInDropdownScopedState, TableContext);
const resetState = useCallback(() => {
setIsOperandSelectionUnfolded(false);
setSelectedFilter(undefined);
setSelectedFilterOperand(undefined);
setFilterSearch(null);
}, [setFilterSearch]);
setIsFilterDropdownOperandSelectUnfolded(false);
setTableFilterDefinitionUsedInDropdown(null);
setSelectedOperandInDropdown(null);
setFilterDropdownSearchInput('');
}, [
setTableFilterDefinitionUsedInDropdown,
setSelectedOperandInDropdown,
setFilterDropdownSearchInput,
setIsFilterDropdownOperandSelectUnfolded,
]);
const renderOperandSelection = selectedFilter?.operands.map(
(filterOperand, index) => (
<DropdownButton.StyledDropdownItem
key={`select-filter-operand-${index}`}
onClick={() => {
setSelectedFilterOperand(filterOperand);
setIsOperandSelectionUnfolded(false);
}}
>
{filterOperand.label}
</DropdownButton.StyledDropdownItem>
),
);
const renderFilterSelection = availableFilters.map((filter, index) => (
<DropdownButton.StyledDropdownItem
key={`select-filter-${index}`}
onClick={() => {
setSelectedFilter(filter);
setSelectedFilterOperand(filter.operands[0]);
filter.searchConfig && setFilterSearch(filter.searchConfig);
setSearchInput('');
}}
>
<DropdownButton.StyledIcon>{filter.icon}</DropdownButton.StyledIcon>
{filter.label}
</DropdownButton.StyledDropdownItem>
));
const renderSearchResults = (
filterSearchResults: SearchResultsType<TData>,
selectedFilter: FilterConfigType<TData>,
selectedFilterOperand: FilterOperandType<TData>,
) => {
if (filterSearchResults.loading) {
return (
<DropdownButton.StyledDropdownItem data-testid="loading-search-results">
Loading
</DropdownButton.StyledDropdownItem>
);
}
function resultIsEntity(result: any): result is { id: string } {
return Object.keys(result ?? {}).includes('id');
}
return (
<>
<DropdownMenuSeparator />
<DropdownMenuItemContainer>
{filterSearchResults.results.map((result, index) => {
return (
<DropdownMenuSelectableItem
key={`fields-value-${index}`}
selected={
resultIsEntity(result.value) &&
result.value.id === selectedEntityId
}
onClick={() => {
if (resultIsEntity(result.value)) {
setSelectedEntityId(result.value.id);
}
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: result.value,
displayValue: result.render(result.value),
icon: selectedFilter.icon,
operand: selectedFilterOperand,
});
setIsUnfolded(false);
setCaptureHotkeyTypeInFocus(false);
setSelectedFilter(undefined);
}}
>
<DropdownButton.StyledDropdownItemClipped>
{result.render(result.value)}
</DropdownButton.StyledDropdownItemClipped>
</DropdownMenuSelectableItem>
);
})}
</DropdownMenuItemContainer>
</>
);
};
function renderValueSelection(
selectedFilter: FilterConfigType<TData>,
selectedFilterOperand: FilterOperandType<TData>,
) {
return (
<>
<DropdownButton.StyledDropdownTopOption
key={'selected-filter-operand'}
onClick={() => setIsOperandSelectionUnfolded(true)}
>
{selectedFilterOperand.label}
<DropdownButton.StyledDropdownTopOptionAngleDown />
</DropdownButton.StyledDropdownTopOption>
<DropdownButton.StyledSearchField autoFocus key={'search-filter'}>
{['text', 'relation'].includes(selectedFilter.type) && (
<input
type="text"
placeholder={selectedFilter.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (
selectedFilter.type === 'relation' &&
selectedFilter.searchConfig
) {
setFilterSearch(selectedFilter.searchConfig);
setSearchInput(event.target.value);
}
if (selectedFilter.type === 'text') {
if (event.target.value === '') {
onFilterRemove(selectedFilter.key);
} else {
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: event.target.value,
displayValue: event.target.value,
icon: selectedFilter.icon,
operand: selectedFilterOperand,
} as SelectedFilterType<TData>);
}
}
}}
/>
)}
{selectedFilter.type === 'date' && (
<DatePicker
date={new Date()}
onChangeHandler={(date) => {
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: date.toISOString(),
displayValue: humanReadableDate(date),
icon: selectedFilter.icon,
operand: selectedFilterOperand,
} as SelectedFilterType<TData>);
}}
customInput={<></>}
customCalendarContainer={styled.div`
top: -10px;
`}
/>
)}
</DropdownButton.StyledSearchField>
{selectedFilter.type === 'relation' &&
filterSearchResults &&
renderSearchResults(
filterSearchResults,
selectedFilter,
selectedFilterOperand,
)}
</>
);
}
const isFilterSelected = (activeTableFilters?.length ?? 0) > 0;
return (
<DropdownButton
@ -231,11 +72,34 @@ export const FilterDropdownButton = <TData extends FilterableFieldsType>({
setIsUnfolded={setIsUnfolded}
resetState={resetState}
>
{selectedFilter && selectedFilterOperand
? isOperandSelectionUnfolded
? renderOperandSelection
: renderValueSelection(selectedFilter, selectedFilterOperand)
: renderFilterSelection}
{!tableFilterDefinitionUsedInDropdown ? (
<FilterDropdownFilterSelect />
) : isFilterDropdownOperandSelectUnfolded ? (
<FilterDropdownOperandSelect />
) : (
selectedOperandInDropdown && (
<>
<FilterDropdownOperandButton />
<DropdownButton.StyledSearchField autoFocus key={'search-filter'}>
{tableFilterDefinitionUsedInDropdown.type === 'text' && (
<FilterDropdownTextSearchInput />
)}
{tableFilterDefinitionUsedInDropdown.type === 'number' && (
<FilterDropdownNumberSearchInput />
)}
{tableFilterDefinitionUsedInDropdown.type === 'date' && (
<FilterDropdownDateSearchInput />
)}
{tableFilterDefinitionUsedInDropdown.type === 'entity' && (
<FilterDropdownEntitySearchInput />
)}
</DropdownButton.StyledSearchField>
{tableFilterDefinitionUsedInDropdown.type === 'entity' && (
<FilterDropdownEntitySelect />
)}
</>
)
)}
</DropdownButton>
);
};
}

View File

@ -0,0 +1,47 @@
import styled from '@emotion/styled';
import { useUpsertActiveTableFilter } from '@/filters-and-sorts/hooks/useUpsertActiveTableFilter';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
import DatePicker from '../../form/DatePicker';
export function FilterDropdownDateSearchInput() {
const [tableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
TableContext,
);
const upsertActiveTableFilter = useUpsertActiveTableFilter();
function handleChange(date: Date) {
if (!tableFilterDefinitionUsedInDropdown || !selectedOperandInDropdown)
return;
upsertActiveTableFilter({
field: tableFilterDefinitionUsedInDropdown.field,
type: tableFilterDefinitionUsedInDropdown.type,
value: date.toISOString(),
operand: selectedOperandInDropdown,
displayValue: date.toLocaleDateString(),
});
}
return (
<DatePicker
date={new Date()}
onChangeHandler={handleChange}
customInput={<></>}
customCalendarContainer={styled.div`
top: -10px;
`}
/>
);
}

View File

@ -0,0 +1,36 @@
import { ChangeEvent } from 'react';
import { filterDropdownSearchInputScopedState } from '@/filters-and-sorts/states/filterDropdownSearchInputScopedState';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
export function FilterDropdownEntitySearchInput() {
const [tableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
TableContext,
);
const [filterDropdownSearchInput, setFilterDropdownSearchInput] =
useRecoilScopedState(filterDropdownSearchInputScopedState, TableContext);
return (
tableFilterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<input
type="text"
value={filterDropdownSearchInput}
placeholder={tableFilterDefinitionUsedInDropdown.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterDropdownSearchInput(event.target.value);
}}
/>
)
);
}

View File

@ -0,0 +1,86 @@
import { useEffect } from 'react';
import { useActiveTableFilterCurrentlyEditedInDropdown } from '@/filters-and-sorts/hooks/useActiveFilterCurrentlyEditedInDropdown';
import { useRemoveActiveTableFilter } from '@/filters-and-sorts/hooks/useRemoveActiveTableFilter';
import { useUpsertActiveTableFilter } from '@/filters-and-sorts/hooks/useUpsertActiveTableFilter';
import { filterDropdownSelectedEntityIdScopedState } from '@/filters-and-sorts/states/filterDropdownSelectedEntityIdScopedState';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { EntitiesForMultipleEntitySelect } from '@/relation-picker/components/MultipleEntitySelect';
import { SingleEntitySelectBase } from '@/relation-picker/components/SingleEntitySelectBase';
import { EntityForSelect } from '@/relation-picker/types/EntityForSelect';
import { TableContext } from '@/ui/tables/states/TableContext';
export function FilterDropdownEntitySearchSelect({
entitiesForSelect,
}: {
entitiesForSelect: EntitiesForMultipleEntitySelect<EntityForSelect>;
}) {
const [filterDropdownSelectedEntityId, setFilterDropdownSelectedEntityId] =
useRecoilScopedState(
filterDropdownSelectedEntityIdScopedState,
TableContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
TableContext,
);
const [tableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
const upsertActiveTableFilter = useUpsertActiveTableFilter();
const removeActiveTableFilter = useRemoveActiveTableFilter();
const activeFilterCurrentlyEditedInDropdown =
useActiveTableFilterCurrentlyEditedInDropdown();
function handleUserSelected(selectedEntity: EntityForSelect) {
if (!tableFilterDefinitionUsedInDropdown || !selectedOperandInDropdown) {
return;
}
const clickedOnAlreadySelectedEntity =
selectedEntity.id === filterDropdownSelectedEntityId;
if (clickedOnAlreadySelectedEntity) {
removeActiveTableFilter(tableFilterDefinitionUsedInDropdown.field);
setFilterDropdownSelectedEntityId(null);
} else {
setFilterDropdownSelectedEntityId(selectedEntity.id);
upsertActiveTableFilter({
displayValue: selectedEntity.name,
field: tableFilterDefinitionUsedInDropdown.field,
operand: selectedOperandInDropdown,
type: tableFilterDefinitionUsedInDropdown.type,
value: selectedEntity.id,
});
}
}
useEffect(() => {
if (!activeFilterCurrentlyEditedInDropdown) {
setFilterDropdownSelectedEntityId(null);
}
}, [
activeFilterCurrentlyEditedInDropdown,
setFilterDropdownSelectedEntityId,
]);
return (
<>
<SingleEntitySelectBase
entities={{
entitiesToSelect: entitiesForSelect.entitiesToSelect,
selectedEntity: entitiesForSelect.selectedEntities[0],
}}
onEntitySelected={handleUserSelected}
/>
</>
);
}

View File

@ -0,0 +1,26 @@
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
import { DropdownMenuSeparator } from '../../menu/DropdownMenuSeparator';
export function FilterDropdownEntitySelect() {
const [tableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
if (tableFilterDefinitionUsedInDropdown?.type !== 'entity') {
return null;
}
return (
<>
<DropdownMenuSeparator />
<RecoilScope>
{tableFilterDefinitionUsedInDropdown.entitySelectComponent}
</RecoilScope>
</>
);
}

View File

@ -0,0 +1,58 @@
import { availableTableFiltersScopedState } from '@/filters-and-sorts/states/availableTableFiltersScopedState';
import { filterDropdownSearchInputScopedState } from '@/filters-and-sorts/states/filterDropdownSearchInputScopedState';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { getOperandsForFilterType } from '@/filters-and-sorts/utils/getOperandsForFilterType';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/recoil-scope/hooks/useRecoilScopedValue';
import { TableContext } from '@/ui/tables/states/TableContext';
import { DropdownMenuItemContainer } from '../../menu/DropdownMenuItemContainer';
import { DropdownMenuSelectableItem } from '../../menu/DropdownMenuSelectableItem';
import DropdownButton from './DropdownButton';
export function FilterDropdownFilterSelect() {
const [, setTableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
const [, setSelectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
TableContext,
);
const [, setFilterDropdownSearchInput] = useRecoilScopedState(
filterDropdownSearchInputScopedState,
TableContext,
);
const availableTableFilters = useRecoilScopedValue(
availableTableFiltersScopedState,
TableContext,
);
return (
<DropdownMenuItemContainer style={{ maxHeight: '300px' }}>
{availableTableFilters.map((availableTableFilter, index) => (
<DropdownMenuSelectableItem
key={`select-filter-${index}`}
onClick={() => {
setTableFilterDefinitionUsedInDropdown(availableTableFilter);
setSelectedOperandInDropdown(
getOperandsForFilterType(availableTableFilter.type)?.[0],
);
setFilterDropdownSearchInput('');
}}
>
<DropdownButton.StyledIcon>
{availableTableFilter.icon}
</DropdownButton.StyledIcon>
{availableTableFilter.label}
</DropdownMenuSelectableItem>
))}
</DropdownMenuItemContainer>
);
}

View File

@ -0,0 +1,46 @@
import { ChangeEvent } from 'react';
import { useRemoveActiveTableFilter } from '@/filters-and-sorts/hooks/useRemoveActiveTableFilter';
import { useUpsertActiveTableFilter } from '@/filters-and-sorts/hooks/useUpsertActiveTableFilter';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
export function FilterDropdownNumberSearchInput() {
const [tableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
TableContext,
);
const upsertActiveTableFilter = useUpsertActiveTableFilter();
const removeActiveTableFilter = useRemoveActiveTableFilter();
return (
tableFilterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<input
type="number"
placeholder={tableFilterDefinitionUsedInDropdown.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (event.target.value === '') {
removeActiveTableFilter(tableFilterDefinitionUsedInDropdown.field);
} else {
upsertActiveTableFilter({
field: tableFilterDefinitionUsedInDropdown.field,
type: tableFilterDefinitionUsedInDropdown.type,
value: event.target.value,
operand: selectedOperandInDropdown,
displayValue: event.target.value,
});
}
}}
/>
)
);
}

View File

@ -0,0 +1,34 @@
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '@/filters-and-sorts/states/isFilterDropdownOperandSelectUnfoldedScopedState';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { getOperandLabel } from '@/filters-and-sorts/utils/getOperandLabel';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
import DropdownButton from './DropdownButton';
export function FilterDropdownOperandButton() {
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
TableContext,
);
const [isOperandSelectionUnfolded, setIsOperandSelectionUnfolded] =
useRecoilScopedState(
isFilterDropdownOperandSelectUnfoldedScopedState,
TableContext,
);
if (isOperandSelectionUnfolded) {
return null;
}
return (
<DropdownButton.StyledDropdownTopOption
key={'selected-filter-operand'}
onClick={() => setIsOperandSelectionUnfolded(true)}
>
{getOperandLabel(selectedOperandInDropdown)}
<DropdownButton.StyledDropdownTopOptionAngleDown />
</DropdownButton.StyledDropdownTopOption>
);
}

View File

@ -0,0 +1,78 @@
import { useActiveTableFilterCurrentlyEditedInDropdown } from '@/filters-and-sorts/hooks/useActiveFilterCurrentlyEditedInDropdown';
import { useUpsertActiveTableFilter } from '@/filters-and-sorts/hooks/useUpsertActiveTableFilter';
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '@/filters-and-sorts/states/isFilterDropdownOperandSelectUnfoldedScopedState';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { TableFilterOperand } from '@/filters-and-sorts/types/TableFilterOperand';
import { getOperandLabel } from '@/filters-and-sorts/utils/getOperandLabel';
import { getOperandsForFilterType } from '@/filters-and-sorts/utils/getOperandsForFilterType';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
import { DropdownMenuItemContainer } from '../../menu/DropdownMenuItemContainer';
import DropdownButton from './DropdownButton';
export function FilterDropdownOperandSelect() {
const [tableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
const [, setSelectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
TableContext,
);
const operandsForFilterType = getOperandsForFilterType(
tableFilterDefinitionUsedInDropdown?.type,
);
const [isOperandSelectionUnfolded, setIsOperandSelectionUnfolded] =
useRecoilScopedState(
isFilterDropdownOperandSelectUnfoldedScopedState,
TableContext,
);
const activeTableFilterCurrentlyEditedInDropdown =
useActiveTableFilterCurrentlyEditedInDropdown();
const upsertActiveTableFilter = useUpsertActiveTableFilter();
function handleOperangeChange(newOperand: TableFilterOperand) {
setSelectedOperandInDropdown(newOperand);
setIsOperandSelectionUnfolded(false);
if (
tableFilterDefinitionUsedInDropdown &&
activeTableFilterCurrentlyEditedInDropdown
) {
upsertActiveTableFilter({
field: activeTableFilterCurrentlyEditedInDropdown.field,
displayValue: activeTableFilterCurrentlyEditedInDropdown.displayValue,
operand: newOperand,
type: activeTableFilterCurrentlyEditedInDropdown.type,
value: activeTableFilterCurrentlyEditedInDropdown.value,
});
}
}
if (!isOperandSelectionUnfolded) {
return <></>;
}
return (
<DropdownMenuItemContainer>
{operandsForFilterType.map((filterOperand, index) => (
<DropdownButton.StyledDropdownItem
key={`select-filter-operand-${index}`}
onClick={() => {
handleOperangeChange(filterOperand);
}}
>
{getOperandLabel(filterOperand)}
</DropdownButton.StyledDropdownItem>
))}
</DropdownMenuItemContainer>
);
}

View File

@ -0,0 +1,60 @@
import { ChangeEvent } from 'react';
import { useActiveTableFilterCurrentlyEditedInDropdown } from '@/filters-and-sorts/hooks/useActiveFilterCurrentlyEditedInDropdown';
import { useRemoveActiveTableFilter } from '@/filters-and-sorts/hooks/useRemoveActiveTableFilter';
import { useUpsertActiveTableFilter } from '@/filters-and-sorts/hooks/useUpsertActiveTableFilter';
import { filterDropdownSearchInputScopedState } from '@/filters-and-sorts/states/filterDropdownSearchInputScopedState';
import { selectedOperandInDropdownScopedState } from '@/filters-and-sorts/states/selectedOperandInDropdownScopedState';
import { tableFilterDefinitionUsedInDropdownScopedState } from '@/filters-and-sorts/states/tableFilterDefinitionUsedInDropdownScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '@/ui/tables/states/TableContext';
export function FilterDropdownTextSearchInput() {
const [tableFilterDefinitionUsedInDropdown] = useRecoilScopedState(
tableFilterDefinitionUsedInDropdownScopedState,
TableContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
TableContext,
);
const [filterDropdownSearchInput, setFilterDropdownSearchInput] =
useRecoilScopedState(filterDropdownSearchInputScopedState, TableContext);
const upsertActiveTableFilter = useUpsertActiveTableFilter();
const removeActiveTableFilter = useRemoveActiveTableFilter();
const activeFilterCurrentlyEditedInDropdown =
useActiveTableFilterCurrentlyEditedInDropdown();
return (
tableFilterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<input
type="text"
placeholder={tableFilterDefinitionUsedInDropdown.label}
value={
activeFilterCurrentlyEditedInDropdown?.value ??
filterDropdownSearchInput
}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterDropdownSearchInput(event.target.value);
if (event.target.value === '') {
removeActiveTableFilter(tableFilterDefinitionUsedInDropdown.field);
} else {
upsertActiveTableFilter({
field: tableFilterDefinitionUsedInDropdown.field,
type: tableFilterDefinitionUsedInDropdown.type,
value: event.target.value,
operand: selectedOperandInDropdown,
displayValue: event.target.value,
});
}
}}
/>
)
);
}

View File

@ -1,20 +1,20 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
FilterableFieldsType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import { useRemoveActiveTableFilter } from '@/filters-and-sorts/hooks/useRemoveActiveTableFilter';
import { SelectedSortType } from '@/filters-and-sorts/interfaces/sorts/interface';
import { activeTableFiltersScopedState } from '@/filters-and-sorts/states/activeTableFiltersScopedState';
import { availableTableFiltersScopedState } from '@/filters-and-sorts/states/availableTableFiltersScopedState';
import { getOperandLabel } from '@/filters-and-sorts/utils/getOperandLabel';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { IconArrowNarrowDown, IconArrowNarrowUp } from '@/ui/icons/index';
import { TableContext } from '@/ui/tables/states/TableContext';
import SortOrFilterChip from './SortOrFilterChip';
type OwnProps<SortField, TData extends FilterableFieldsType> = {
type OwnProps<SortField> = {
sorts: Array<SelectedSortType<SortField>>;
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
filters: Array<SelectedFilterType<TData>>;
onRemoveFilter: (filterId: SelectedFilterType<TData>['key']) => void;
onCancelClick: () => void;
};
@ -59,14 +59,49 @@ const StyledCancelButton = styled.button`
}
`;
function SortAndFilterBar<SortField, TData extends FilterableFieldsType>({
function SortAndFilterBar<SortField>({
sorts,
onRemoveSort,
filters,
onRemoveFilter,
onCancelClick,
}: OwnProps<SortField, TData>) {
}: OwnProps<SortField>) {
const theme = useTheme();
const [activeTableFilters, setActiveTableFilters] = useRecoilScopedState(
activeTableFiltersScopedState,
TableContext,
);
const [availableTableFilters] = useRecoilScopedState(
availableTableFiltersScopedState,
TableContext,
);
const activeTableFiltersWithDefinition = activeTableFilters.map(
(activeTableFilter) => {
const tableFilterDefinition = availableTableFilters.find(
(availableTableFilter) => {
return availableTableFilter.field === activeTableFilter.field;
},
);
return {
...activeTableFilter,
...tableFilterDefinition,
};
},
);
const removeActiveTableFilter = useRemoveActiveTableFilter();
function handleCancelClick() {
setActiveTableFilters([]);
onCancelClick();
}
if (!activeTableFiltersWithDefinition.length && !sorts.length) {
return null;
}
return (
<StyledBar>
<StyledChipcontainer>
@ -87,23 +122,27 @@ function SortAndFilterBar<SortField, TData extends FilterableFieldsType>({
/>
);
})}
{filters.map((filter) => {
{activeTableFiltersWithDefinition.map((filter) => {
return (
<SortOrFilterChip
key={filter.key}
key={filter.field}
labelKey={filter.label}
labelValue={`${filter.operand.label} ${filter.displayValue}`}
id={filter.key}
labelValue={`${getOperandLabel(filter.operand)} ${
filter.displayValue
}`}
id={filter.field}
icon={filter.icon}
onRemove={() => onRemoveFilter(filter.key)}
onRemove={() => {
removeActiveTableFilter(filter.field);
}}
/>
);
})}
</StyledChipcontainer>
{filters.length + sorts.length > 0 && (
{activeTableFilters.length + sorts.length > 0 && (
<StyledCancelButton
data-testid={'cancel-button'}
onClick={onCancelClick}
onClick={handleCancelClick}
>
Cancel
</StyledCancelButton>

View File

@ -1,11 +1,6 @@
import { ReactNode, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import {
FilterableFieldsType,
FilterConfigType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import {
SelectedSortType,
SortType,
@ -15,13 +10,11 @@ import { FilterDropdownButton } from './FilterDropdownButton';
import SortAndFilterBar from './SortAndFilterBar';
import { SortDropdownButton } from './SortDropdownButton';
type OwnProps<SortField, TData extends FilterableFieldsType> = {
type OwnProps<SortField> = {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
availableFilters?: FilterConfigType<TData>[];
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onFiltersUpdate?: (sorts: Array<SelectedFilterType<TData>>) => void;
};
const StyledContainer = styled.div`
@ -60,20 +53,15 @@ const StyledFilters = styled.div`
gap: 2px;
`;
export function TableHeader<SortField, TData extends FilterableFieldsType>({
export function TableHeader<SortField>({
viewName,
viewIcon,
availableSorts,
availableFilters,
onSortsUpdate,
onFiltersUpdate,
}: OwnProps<SortField, TData>) {
}: OwnProps<SortField>) {
const [sorts, innerSetSorts] = useState<Array<SelectedSortType<SortField>>>(
[],
);
const [filters, innerSetFilters] = useState<Array<SelectedFilterType<TData>>>(
[],
);
const sortSelect = useCallback(
(newSort: SelectedSortType<SortField>) => {
@ -93,25 +81,6 @@ export function TableHeader<SortField, TData extends FilterableFieldsType>({
[onSortsUpdate, sorts],
);
const filterSelect = useCallback(
(filter: SelectedFilterType<TData>) => {
const newFilters = updateSortOrFilterByKey(filters, filter);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
},
[onFiltersUpdate, filters],
);
const filterUnselect = useCallback(
(filterId: SelectedFilterType<TData>['key']) => {
const newFilters = filters.filter((filter) => filter.key !== filterId);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
},
[onFiltersUpdate, filters],
);
return (
<StyledContainer>
<StyledTableHeader>
@ -120,12 +89,7 @@ export function TableHeader<SortField, TData extends FilterableFieldsType>({
{viewName}
</StyledViewSection>
<StyledFilters>
<FilterDropdownButton
isFilterSelected={filters.length > 0}
availableFilters={availableFilters || []}
onFilterSelect={filterSelect}
onFilterRemove={filterUnselect}
/>
<FilterDropdownButton />
<SortDropdownButton<SortField>
isSortSelected={sorts.length > 0}
availableSorts={availableSorts || []}
@ -133,20 +97,14 @@ export function TableHeader<SortField, TData extends FilterableFieldsType>({
/>
</StyledFilters>
</StyledTableHeader>
{sorts.length + filters.length > 0 && (
<SortAndFilterBar
sorts={sorts}
filters={filters}
onRemoveSort={sortUnselect}
onRemoveFilter={filterUnselect}
onCancelClick={() => {
innerSetFilters([]);
onFiltersUpdate && onFiltersUpdate([]);
innerSetSorts([]);
onSortsUpdate && onSortsUpdate([]);
}}
/>
)}
<SortAndFilterBar
sorts={sorts}
onRemoveSort={sortUnselect}
onCancelClick={() => {
innerSetSorts([]);
onSortsUpdate && onSortsUpdate([]);
}}
/>
</StyledContainer>
);
}

View File

@ -1,11 +1,9 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { IconList } from '@/ui/icons/index';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { getRenderWrapperForEntityTableComponent } from '~/testing/renderWrappers';
import { availableFilters } from '../../../../../../pages/companies/companies-filters';
import { availableSorts } from '../../../../../../pages/companies/companies-sorts';
import { TableHeader } from '../TableHeader';
@ -18,23 +16,21 @@ export default meta;
type Story = StoryObj<typeof TableHeader>;
export const Empty: Story = {
render: getRenderWrapperForComponent(
render: getRenderWrapperForEntityTableComponent(
<TableHeader
viewName="ViewName"
viewIcon={<IconList />}
availableSorts={availableSorts}
availableFilters={availableFilters}
/>,
),
};
export const WithSortsAndFilters: Story = {
render: getRenderWrapperForComponent(
render: getRenderWrapperForEntityTableComponent(
<TableHeader
viewName="ViewName"
viewIcon={<IconList />}
availableSorts={availableSorts}
availableFilters={availableFilters}
/>,
),
play: async ({ canvasElement }) => {
@ -65,7 +61,7 @@ export const WithSortsAndFilters: Story = {
userEvent.click(await canvas.findByText('Url'));
userEvent.click(await canvas.findByText('Filter'));
userEvent.click(await canvas.findByText('Created At'));
userEvent.click(await canvas.findByText('Created at'));
userEvent.click(await canvas.findByText('6'));
userEvent.click(outsideClick);
},

View File

@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { availableTableFiltersScopedState } from '@/filters-and-sorts/states/availableTableFiltersScopedState';
import { TableFilterDefinition } from '@/filters-and-sorts/types/TableFilterDefinition';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { TableContext } from '../states/TableContext';
export function useInitializeEntityTableFilters({
availableTableFilters,
}: {
availableTableFilters: TableFilterDefinition[];
}) {
const [, setAvailableTableFilters] = useRecoilScopedState(
availableTableFiltersScopedState,
TableContext,
);
useEffect(() => {
setAvailableTableFilters(availableTableFilters);
}, [setAvailableTableFilters, availableTableFilters]);
}

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const TableContext = createContext<string | null>(null);

View File

@ -0,0 +1,41 @@
import { filterDropdownSearchInputScopedState } from '@/filters-and-sorts/states/filterDropdownSearchInputScopedState';
import { filterDropdownSelectedEntityIdScopedState } from '@/filters-and-sorts/states/filterDropdownSelectedEntityIdScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/recoil-scope/hooks/useRecoilScopedValue';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { Entity } from '@/relation-picker/types/EntityTypeForSelect';
import { FilterDropdownEntitySearchSelect } from '@/ui/components/table/table-header/FilterDropdownEntitySearchSelect';
import { TableContext } from '@/ui/tables/states/TableContext';
import { useSearchUserQuery } from '~/generated/graphql';
export function FilterDropdownUserSearchSelect() {
const filterDropdownSearchInput = useRecoilScopedValue(
filterDropdownSearchInputScopedState,
TableContext,
);
const [filterDropdownSelectedEntityId] = useRecoilScopedState(
filterDropdownSelectedEntityIdScopedState,
TableContext,
);
const usersForSelect = useFilteredSearchEntityQuery({
queryHook: useSearchUserQuery,
searchOnFields: ['firstName', 'lastName'],
orderByField: 'lastName',
selectedIds: filterDropdownSelectedEntityId
? [filterDropdownSelectedEntityId]
: [],
mappingFunction: (entity) => ({
id: entity.id,
entityType: Entity.User,
name: `${entity.displayName}`,
avatarType: 'rounded',
}),
searchFilter: filterDropdownSearchInput,
});
return (
<FilterDropdownEntitySearchSelect entitiesForSelect={usersForSelect} />
);
}

View File

@ -6,6 +6,8 @@ export const GET_CURRENT_USER = gql`
id
email
displayName
firstName
lastName
workspaceMember {
id
workspace {
@ -25,6 +27,8 @@ export const GET_USERS = gql`
id
email
displayName
firstName
lastName
}
}
`;