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:
@ -18,6 +18,8 @@ export const VERIFY = gql`
|
||||
id
|
||||
email
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
workspaceMember {
|
||||
id
|
||||
workspace {
|
||||
|
||||
@ -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() ?? '',
|
||||
|
||||
@ -23,6 +23,8 @@ export const CREATE_COMMENT = gql`
|
||||
author {
|
||||
id
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
avatarUrl
|
||||
}
|
||||
commentThreadId
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ export function CompanyAccountOwnerPicker({ company }: OwnProps) {
|
||||
avatarType: 'rounded',
|
||||
}),
|
||||
orderByField: 'displayName',
|
||||
searchOnFields: ['displayName'],
|
||||
searchOnFields: ['firstName', 'lastName'],
|
||||
});
|
||||
|
||||
async function handleEntitySelected(selectedUser: UserForSelect) {
|
||||
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
@ -27,6 +27,8 @@ export const GET_COMPANIES = gql`
|
||||
id
|
||||
email
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,8 @@ export const UPDATE_COMPANY = gql`
|
||||
id
|
||||
email
|
||||
displayName
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
address
|
||||
createdAt
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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>;
|
||||
};
|
||||
@ -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: [],
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
import { TableFilterDefinition } from '../types/TableFilterDefinition';
|
||||
|
||||
export const availableTableFiltersScopedState = atomFamily<
|
||||
TableFilterDefinition[],
|
||||
string
|
||||
>({
|
||||
key: 'availableTableFiltersScopedState',
|
||||
default: [],
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const filterDropdownSearchInputScopedState = atomFamily<string, string>({
|
||||
key: 'filterDropdownSearchInputScopedState',
|
||||
default: '',
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const filterDropdownSelectedEntityIdScopedState = atomFamily<
|
||||
string | null,
|
||||
string
|
||||
>({
|
||||
key: 'filterDropdownSelectedEntityIdScopedState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const isFilterDropdownOperandSelectUnfoldedScopedState = atomFamily<
|
||||
boolean,
|
||||
string
|
||||
>({
|
||||
key: 'isFilterDropdownOperandSelectUnfoldedScopedState',
|
||||
default: false,
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
import { TableFilterOperand } from '../types/TableFilterOperand';
|
||||
|
||||
export const selectedOperandInDropdownScopedState = atomFamily<
|
||||
TableFilterOperand | null,
|
||||
string
|
||||
>({
|
||||
key: 'selectedOperandInDropdownScopedState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
import { TableFilterDefinition } from '../types/TableFilterDefinition';
|
||||
|
||||
export const tableFilterDefinitionUsedInDropdownScopedState = atomFamily<
|
||||
TableFilterDefinition | null,
|
||||
string
|
||||
>({
|
||||
key: 'tableFilterDefinitionUsedInDropdownScopedState',
|
||||
default: null,
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
export type FilterSearchResult = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { TableFilterType } from './TableFilterType';
|
||||
|
||||
export type TableFilterDefinition = {
|
||||
field: string;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
type: TableFilterType;
|
||||
entitySelectComponent?: JSX.Element;
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
import { TableFilterDefinition } from './TableFilterDefinition';
|
||||
|
||||
export type TableFilterDefinitionByEntity<T> = TableFilterDefinition & {
|
||||
field: keyof T;
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
export type TableFilterOperand =
|
||||
| 'contains'
|
||||
| 'does-not-contain'
|
||||
| 'greater-than'
|
||||
| 'less-than'
|
||||
| 'is'
|
||||
| 'is-not';
|
||||
@ -0,0 +1 @@
|
||||
export type TableFilterType = 'text' | 'date' | 'entity' | 'number';
|
||||
22
front/src/modules/filters-and-sorts/utils/getOperandLabel.ts
Normal file
22
front/src/modules/filters-and-sorts/utils/getOperandLabel.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const relationPickerHoverIndexScopedState = atomFamily<number, string>({
|
||||
key: 'relationPickerHoverIndexScopedState',
|
||||
default: 0,
|
||||
});
|
||||
@ -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];
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ import { hoverBackground } from '@/ui/themes/effects';
|
||||
import { DropdownMenuButton } from './DropdownMenuButton';
|
||||
|
||||
type Props = {
|
||||
selected: boolean;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
hovered?: boolean;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 <></>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
3
front/src/modules/ui/tables/states/TableContext.ts
Normal file
3
front/src/modules/ui/tables/states/TableContext.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const TableContext = createContext<string | null>(null);
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user