306 implement multi relation picker for person and try to factorize relation picker (#319)

* Removed useless folder

* First working version

* Refactored MultipleEntitySelect and splitted into 2 components

* Added TODO

* Removed useless Query

* Fixed refetch

* Fixed naming

* Fix tests

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau 2023-06-17 10:13:30 +02:00 committed by GitHub
parent 7f25f16766
commit d13ceb98fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 604 additions and 369 deletions

View File

@ -1675,35 +1675,36 @@ export type DeletePeopleMutationVariables = Exact<{
export type DeletePeopleMutation = { __typename?: 'Mutation', deleteManyPerson: { __typename?: 'AffectedRows', count: number } }; export type DeletePeopleMutation = { __typename?: 'Mutation', deleteManyPerson: { __typename?: 'AffectedRows', count: number } };
export type SearchPeopleQueryQueryVariables = Exact<{ export type SearchPeopleQueryVariables = Exact<{
where?: InputMaybe<PersonWhereInput>; where?: InputMaybe<PersonWhereInput>;
limit?: InputMaybe<Scalars['Int']>; limit?: InputMaybe<Scalars['Int']>;
orderBy?: InputMaybe<Array<PersonOrderByWithRelationInput> | PersonOrderByWithRelationInput>;
}>; }>;
export type SearchPeopleQueryQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'Person', id: string, phone: string, email: string, city: string, firstname: string, lastname: string, createdAt: string }> }; export type SearchPeopleQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'Person', id: string, phone: string, email: string, city: string, firstname: string, lastname: string, createdAt: string }> };
export type SearchUserQueryQueryVariables = Exact<{ export type SearchUserQueryVariables = Exact<{
where?: InputMaybe<UserWhereInput>; where?: InputMaybe<UserWhereInput>;
limit?: InputMaybe<Scalars['Int']>; limit?: InputMaybe<Scalars['Int']>;
}>; }>;
export type SearchUserQueryQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'User', id: string, email: string, displayName: string }> }; export type SearchUserQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'User', id: string, email: string, displayName: string }> };
export type EmptyQueryQueryVariables = Exact<{ [key: string]: never; }>; export type EmptyQueryQueryVariables = Exact<{ [key: string]: never; }>;
export type EmptyQueryQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'User', id: string }> }; export type EmptyQueryQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'User', id: string }> };
export type SearchCompanyQueryQueryVariables = Exact<{ export type SearchCompanyQueryVariables = Exact<{
where?: InputMaybe<CompanyWhereInput>; where?: InputMaybe<CompanyWhereInput>;
limit?: InputMaybe<Scalars['Int']>; limit?: InputMaybe<Scalars['Int']>;
orderBy?: InputMaybe<Array<CompanyOrderByWithRelationInput> | CompanyOrderByWithRelationInput>; orderBy?: InputMaybe<Array<CompanyOrderByWithRelationInput> | CompanyOrderByWithRelationInput>;
}>; }>;
export type SearchCompanyQueryQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'Company', id: string, name: string, domainName: string }> }; export type SearchCompanyQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'Company', id: string, name: string, domainName: string }> };
export type GetCurrentUserQueryVariables = Exact<{ export type GetCurrentUserQueryVariables = Exact<{
uuid?: InputMaybe<Scalars['String']>; uuid?: InputMaybe<Scalars['String']>;
@ -2433,9 +2434,9 @@ export function useDeletePeopleMutation(baseOptions?: Apollo.MutationHookOptions
export type DeletePeopleMutationHookResult = ReturnType<typeof useDeletePeopleMutation>; export type DeletePeopleMutationHookResult = ReturnType<typeof useDeletePeopleMutation>;
export type DeletePeopleMutationResult = Apollo.MutationResult<DeletePeopleMutation>; export type DeletePeopleMutationResult = Apollo.MutationResult<DeletePeopleMutation>;
export type DeletePeopleMutationOptions = Apollo.BaseMutationOptions<DeletePeopleMutation, DeletePeopleMutationVariables>; export type DeletePeopleMutationOptions = Apollo.BaseMutationOptions<DeletePeopleMutation, DeletePeopleMutationVariables>;
export const SearchPeopleQueryDocument = gql` export const SearchPeopleDocument = gql`
query SearchPeopleQuery($where: PersonWhereInput, $limit: Int) { query SearchPeople($where: PersonWhereInput, $limit: Int, $orderBy: [PersonOrderByWithRelationInput!]) {
searchResults: findManyPerson(where: $where, take: $limit) { searchResults: findManyPerson(where: $where, take: $limit, orderBy: $orderBy) {
id id
phone phone
email email
@ -2448,35 +2449,36 @@ export const SearchPeopleQueryDocument = gql`
`; `;
/** /**
* __useSearchPeopleQueryQuery__ * __useSearchPeopleQuery__
* *
* To run a query within a React component, call `useSearchPeopleQueryQuery` and pass it any options that fit your needs. * To run a query within a React component, call `useSearchPeopleQuery` and pass it any options that fit your needs.
* When your component renders, `useSearchPeopleQueryQuery` returns an object from Apollo Client that contains loading, error, and data properties * When your component renders, `useSearchPeopleQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI. * you can use to render your UI.
* *
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
* *
* @example * @example
* const { data, loading, error } = useSearchPeopleQueryQuery({ * const { data, loading, error } = useSearchPeopleQuery({
* variables: { * variables: {
* where: // value for 'where' * where: // value for 'where'
* limit: // value for 'limit' * limit: // value for 'limit'
* orderBy: // value for 'orderBy'
* }, * },
* }); * });
*/ */
export function useSearchPeopleQueryQuery(baseOptions?: Apollo.QueryHookOptions<SearchPeopleQueryQuery, SearchPeopleQueryQueryVariables>) { export function useSearchPeopleQuery(baseOptions?: Apollo.QueryHookOptions<SearchPeopleQuery, SearchPeopleQueryVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<SearchPeopleQueryQuery, SearchPeopleQueryQueryVariables>(SearchPeopleQueryDocument, options); return Apollo.useQuery<SearchPeopleQuery, SearchPeopleQueryVariables>(SearchPeopleDocument, options);
} }
export function useSearchPeopleQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SearchPeopleQueryQuery, SearchPeopleQueryQueryVariables>) { export function useSearchPeopleLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SearchPeopleQuery, SearchPeopleQueryVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<SearchPeopleQueryQuery, SearchPeopleQueryQueryVariables>(SearchPeopleQueryDocument, options); return Apollo.useLazyQuery<SearchPeopleQuery, SearchPeopleQueryVariables>(SearchPeopleDocument, options);
} }
export type SearchPeopleQueryQueryHookResult = ReturnType<typeof useSearchPeopleQueryQuery>; export type SearchPeopleQueryHookResult = ReturnType<typeof useSearchPeopleQuery>;
export type SearchPeopleQueryLazyQueryHookResult = ReturnType<typeof useSearchPeopleQueryLazyQuery>; export type SearchPeopleLazyQueryHookResult = ReturnType<typeof useSearchPeopleLazyQuery>;
export type SearchPeopleQueryQueryResult = Apollo.QueryResult<SearchPeopleQueryQuery, SearchPeopleQueryQueryVariables>; export type SearchPeopleQueryResult = Apollo.QueryResult<SearchPeopleQuery, SearchPeopleQueryVariables>;
export const SearchUserQueryDocument = gql` export const SearchUserDocument = gql`
query SearchUserQuery($where: UserWhereInput, $limit: Int) { query SearchUser($where: UserWhereInput, $limit: Int) {
searchResults: findManyUser(where: $where, take: $limit) { searchResults: findManyUser(where: $where, take: $limit) {
id id
email email
@ -2486,33 +2488,33 @@ export const SearchUserQueryDocument = gql`
`; `;
/** /**
* __useSearchUserQueryQuery__ * __useSearchUserQuery__
* *
* To run a query within a React component, call `useSearchUserQueryQuery` and pass it any options that fit your needs. * To run a query within a React component, call `useSearchUserQuery` and pass it any options that fit your needs.
* When your component renders, `useSearchUserQueryQuery` returns an object from Apollo Client that contains loading, error, and data properties * When your component renders, `useSearchUserQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI. * you can use to render your UI.
* *
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
* *
* @example * @example
* const { data, loading, error } = useSearchUserQueryQuery({ * const { data, loading, error } = useSearchUserQuery({
* variables: { * variables: {
* where: // value for 'where' * where: // value for 'where'
* limit: // value for 'limit' * limit: // value for 'limit'
* }, * },
* }); * });
*/ */
export function useSearchUserQueryQuery(baseOptions?: Apollo.QueryHookOptions<SearchUserQueryQuery, SearchUserQueryQueryVariables>) { export function useSearchUserQuery(baseOptions?: Apollo.QueryHookOptions<SearchUserQuery, SearchUserQueryVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<SearchUserQueryQuery, SearchUserQueryQueryVariables>(SearchUserQueryDocument, options); return Apollo.useQuery<SearchUserQuery, SearchUserQueryVariables>(SearchUserDocument, options);
} }
export function useSearchUserQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SearchUserQueryQuery, SearchUserQueryQueryVariables>) { export function useSearchUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SearchUserQuery, SearchUserQueryVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<SearchUserQueryQuery, SearchUserQueryQueryVariables>(SearchUserQueryDocument, options); return Apollo.useLazyQuery<SearchUserQuery, SearchUserQueryVariables>(SearchUserDocument, options);
} }
export type SearchUserQueryQueryHookResult = ReturnType<typeof useSearchUserQueryQuery>; export type SearchUserQueryHookResult = ReturnType<typeof useSearchUserQuery>;
export type SearchUserQueryLazyQueryHookResult = ReturnType<typeof useSearchUserQueryLazyQuery>; export type SearchUserLazyQueryHookResult = ReturnType<typeof useSearchUserLazyQuery>;
export type SearchUserQueryQueryResult = Apollo.QueryResult<SearchUserQueryQuery, SearchUserQueryQueryVariables>; export type SearchUserQueryResult = Apollo.QueryResult<SearchUserQuery, SearchUserQueryVariables>;
export const EmptyQueryDocument = gql` export const EmptyQueryDocument = gql`
query EmptyQuery { query EmptyQuery {
searchResults: findManyUser { searchResults: findManyUser {
@ -2547,8 +2549,8 @@ export function useEmptyQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions
export type EmptyQueryQueryHookResult = ReturnType<typeof useEmptyQueryQuery>; export type EmptyQueryQueryHookResult = ReturnType<typeof useEmptyQueryQuery>;
export type EmptyQueryLazyQueryHookResult = ReturnType<typeof useEmptyQueryLazyQuery>; export type EmptyQueryLazyQueryHookResult = ReturnType<typeof useEmptyQueryLazyQuery>;
export type EmptyQueryQueryResult = Apollo.QueryResult<EmptyQueryQuery, EmptyQueryQueryVariables>; export type EmptyQueryQueryResult = Apollo.QueryResult<EmptyQueryQuery, EmptyQueryQueryVariables>;
export const SearchCompanyQueryDocument = gql` export const SearchCompanyDocument = gql`
query SearchCompanyQuery($where: CompanyWhereInput, $limit: Int, $orderBy: [CompanyOrderByWithRelationInput!]) { query SearchCompany($where: CompanyWhereInput, $limit: Int, $orderBy: [CompanyOrderByWithRelationInput!]) {
searchResults: findManyCompany(where: $where, take: $limit, orderBy: $orderBy) { searchResults: findManyCompany(where: $where, take: $limit, orderBy: $orderBy) {
id id
name name
@ -2558,16 +2560,16 @@ export const SearchCompanyQueryDocument = gql`
`; `;
/** /**
* __useSearchCompanyQueryQuery__ * __useSearchCompanyQuery__
* *
* To run a query within a React component, call `useSearchCompanyQueryQuery` and pass it any options that fit your needs. * To run a query within a React component, call `useSearchCompanyQuery` and pass it any options that fit your needs.
* When your component renders, `useSearchCompanyQueryQuery` returns an object from Apollo Client that contains loading, error, and data properties * When your component renders, `useSearchCompanyQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI. * you can use to render your UI.
* *
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
* *
* @example * @example
* const { data, loading, error } = useSearchCompanyQueryQuery({ * const { data, loading, error } = useSearchCompanyQuery({
* variables: { * variables: {
* where: // value for 'where' * where: // value for 'where'
* limit: // value for 'limit' * limit: // value for 'limit'
@ -2575,17 +2577,17 @@ export const SearchCompanyQueryDocument = gql`
* }, * },
* }); * });
*/ */
export function useSearchCompanyQueryQuery(baseOptions?: Apollo.QueryHookOptions<SearchCompanyQueryQuery, SearchCompanyQueryQueryVariables>) { export function useSearchCompanyQuery(baseOptions?: Apollo.QueryHookOptions<SearchCompanyQuery, SearchCompanyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<SearchCompanyQueryQuery, SearchCompanyQueryQueryVariables>(SearchCompanyQueryDocument, options); return Apollo.useQuery<SearchCompanyQuery, SearchCompanyQueryVariables>(SearchCompanyDocument, options);
} }
export function useSearchCompanyQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SearchCompanyQueryQuery, SearchCompanyQueryQueryVariables>) { export function useSearchCompanyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<SearchCompanyQuery, SearchCompanyQueryVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<SearchCompanyQueryQuery, SearchCompanyQueryQueryVariables>(SearchCompanyQueryDocument, options); return Apollo.useLazyQuery<SearchCompanyQuery, SearchCompanyQueryVariables>(SearchCompanyDocument, options);
} }
export type SearchCompanyQueryQueryHookResult = ReturnType<typeof useSearchCompanyQueryQuery>; export type SearchCompanyQueryHookResult = ReturnType<typeof useSearchCompanyQuery>;
export type SearchCompanyQueryLazyQueryHookResult = ReturnType<typeof useSearchCompanyQueryLazyQuery>; export type SearchCompanyLazyQueryHookResult = ReturnType<typeof useSearchCompanyLazyQuery>;
export type SearchCompanyQueryQueryResult = Apollo.QueryResult<SearchCompanyQueryQuery, SearchCompanyQueryQueryVariables>; export type SearchCompanyQueryResult = Apollo.QueryResult<SearchCompanyQuery, SearchCompanyQueryVariables>;
export const GetCurrentUserDocument = gql` export const GetCurrentUserDocument = gql`
query GetCurrentUser($uuid: String) { query GetCurrentUser($uuid: String) {
users: findManyUser(where: {id: {equals: $uuid}}) { users: findManyUser(where: {id: {equals: $uuid}}) {

View File

@ -0,0 +1,235 @@
import { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
autoUpdate,
flip,
offset,
size,
useFloating,
} from '@floating-ui/react';
import { IconArrowUpRight } from '@tabler/icons-react';
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import CompanyChip from '@/companies/components/CompanyChip';
import { PersonChip } from '@/people/components/PersonChip';
import { useFilteredSearchEntityQuery } from '@/ui/hooks/menu/useFilteredSearchEntityQuery';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { flatMapAndSortEntityForSelectArrayOfArrayByName } from '@/ui/utils/flatMapAndSortEntityForSelectArrayByName';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
useSearchCompanyQuery,
useSearchPeopleQuery,
} from '~/generated/graphql';
import { useHandleCheckableCommentThreadTargetChange } from '../hooks/useHandleCheckableCommentThreadTargetChange';
import { MultipleEntitySelect } from './MultipleEntitySelect';
type OwnProps = {
commentThread: CommentThreadForDrawer;
};
const StyledContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: row;
gap: ${(props) => props.theme.spacing(2)};
justify-content: flex-start;
width: 100%;
`;
const StyledLabelContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${(props) => props.theme.spacing(2)};
padding-bottom: ${(props) => props.theme.spacing(2)};
padding-top: ${(props) => props.theme.spacing(2)};
`;
const StyledRelationLabel = styled.div`
color: ${(props) => props.theme.text60};
display: flex;
flex-direction: row;
user-select: none;
`;
const StyledRelationContainer = styled.div`
--horizontal-padding: ${(props) => props.theme.spacing(1)};
--vertical-padding: ${(props) => props.theme.spacing(1.5)};
border: 1px solid transparent;
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: ${(props) => props.theme.spacing(2)};
&:hover {
background-color: ${(props) => props.theme.secondaryBackground};
border: 1px solid ${(props) => props.theme.lightBorder};
}
min-height: calc(32px - 2 * var(--vertical-padding));
overflow: hidden;
padding: var(--vertical-padding) var(--horizontal-padding);
width: calc(100% - 2 * var(--horizontal-padding));
`;
const StyledMenuWrapper = styled.div`
z-index: ${(props) => props.theme.lastLayerZIndex};
`;
export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchFilter, setSearchFilter] = useState('');
const theme = useTheme();
const peopleIds =
commentThread.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Person')
.map((relation) => relation.commentableId) ?? [];
const companyIds =
commentThread.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Company')
.map((relation) => relation.commentableId) ?? [];
const personsForMultiSelect = useFilteredSearchEntityQuery({
queryHook: useSearchPeopleQuery,
searchOnFields: ['firstname', 'lastname'],
orderByField: 'lastname',
selectedIds: peopleIds,
mappingFunction: (entity) => ({
id: entity.id,
entityType: CommentableType.Person,
name: `${entity.firstname} ${entity.lastname}`,
avatarType: 'rounded',
}),
searchFilter,
});
const companiesForMultiSelect = useFilteredSearchEntityQuery({
queryHook: useSearchCompanyQuery,
searchOnFields: ['name'],
orderByField: 'name',
selectedIds: companyIds,
mappingFunction: (company) => ({
id: company.id,
entityType: CommentableType.Company,
name: company.name,
avatarUrl: getLogoUrlFromDomainName(company.domainName),
avatarType: 'squared',
}),
searchFilter,
});
function handleRelationContainerClick() {
setIsMenuOpen((isOpen) => !isOpen);
}
// TODO: Place in a scoped recoil atom family
function handleFilterChange(newSearchFilter: string) {
setSearchFilter(newSearchFilter);
}
const handleCheckItemChange = useHandleCheckableCommentThreadTargetChange({
commentThread,
});
function exitEditMode() {
setIsMenuOpen(false);
setSearchFilter('');
}
useHotkeys(
['esc', 'enter'],
() => {
exitEditMode();
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[exitEditMode],
);
const { refs, floatingStyles } = useFloating({
strategy: 'absolute',
middleware: [offset(), flip(), size()],
whileElementsMounted: autoUpdate,
open: isMenuOpen,
placement: 'bottom-start',
});
useListenClickOutsideArrayOfRef([refs.floating, refs.domReference], () => {
exitEditMode();
});
const selectedEntities = flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.selectedEntities,
companiesForMultiSelect.selectedEntities,
]);
const filteredSelectedEntities =
flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.filteredSelectedEntities,
companiesForMultiSelect.filteredSelectedEntities,
]);
const entitiesToSelect = flatMapAndSortEntityForSelectArrayOfArrayByName([
personsForMultiSelect.entitiesToSelect,
companiesForMultiSelect.entitiesToSelect,
]);
return (
<StyledContainer>
<StyledLabelContainer>
<IconArrowUpRight size={16} color={theme.text40} />
<StyledRelationLabel>Relations</StyledRelationLabel>
</StyledLabelContainer>
<StyledRelationContainer
ref={refs.setReference}
onClick={handleRelationContainerClick}
>
{selectedEntities?.map((entity) =>
entity.entityType === CommentableType.Company ? (
<CompanyChip
key={entity.id}
name={entity.name}
picture={entity.avatarUrl}
/>
) : (
<PersonChip key={entity.id} name={entity.name} />
),
)}
</StyledRelationContainer>
{isMenuOpen && (
<StyledMenuWrapper ref={refs.setFloating} style={floatingStyles}>
<MultipleEntitySelect
entities={{
entitiesToSelect,
filteredSelectedEntities,
selectedEntities,
}}
onItemCheckChange={handleCheckItemChange}
onSearchFilterChange={handleFilterChange}
searchFilter={searchFilter}
/>
</StyledMenuWrapper>
)}
</StyledContainer>
);
}

View File

@ -0,0 +1,88 @@
import { debounce } from 'lodash';
import { DropdownMenu } from '@/ui/components/menu/DropdownMenu';
import { DropdownMenuCheckableItem } from '@/ui/components/menu/DropdownMenuCheckableItem';
import { DropdownMenuItem } from '@/ui/components/menu/DropdownMenuItem';
import { DropdownMenuItemContainer } from '@/ui/components/menu/DropdownMenuItemContainer';
import { DropdownMenuSearch } from '@/ui/components/menu/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/components/menu/DropdownMenuSeparator';
import { Avatar, AvatarType } from '@/users/components/Avatar';
import { CommentableType } from '~/generated/graphql';
export type EntitiesForMultipleEntitySelect = {
selectedEntities: EntityForSelect[];
filteredSelectedEntities: EntityForSelect[];
entitiesToSelect: EntityForSelect[];
};
export type EntityTypeForSelect = CommentableType; // TODO: derivate from all usable entity types
export type EntityForSelect = {
id: string;
entityType: EntityTypeForSelect;
name: string;
avatarUrl?: string;
avatarType?: AvatarType;
};
export function MultipleEntitySelect({
entities,
onItemCheckChange,
onSearchFilterChange,
searchFilter,
}: {
entities: EntitiesForMultipleEntitySelect;
searchFilter: string;
onSearchFilterChange: (newSearchFilter: string) => void;
onItemCheckChange: (
newCheckedValue: boolean,
entity: EntityForSelect,
) => void;
}) {
const debouncedSetSearchFilter = debounce(onSearchFilterChange, 100, {
leading: true,
});
function handleFilterChange(event: React.ChangeEvent<HTMLInputElement>) {
debouncedSetSearchFilter(event.currentTarget.value);
onSearchFilterChange(event.currentTarget.value);
}
const entitiesInDropdown = [
...(entities.filteredSelectedEntities ?? []),
...(entities.entitiesToSelect ?? []),
];
return (
<DropdownMenu>
<DropdownMenuSearch value={searchFilter} onChange={handleFilterChange} />
<DropdownMenuSeparator />
<DropdownMenuItemContainer>
{entitiesInDropdown?.map((entity) => (
<DropdownMenuCheckableItem
key={entity.id}
checked={
entities.selectedEntities
?.map((selectedEntity) => selectedEntity.id)
?.includes(entity.id) ?? false
}
onChange={(newCheckedValue) =>
onItemCheckChange(newCheckedValue, entity)
}
>
<Avatar
avatarUrl={entity.avatarUrl}
placeholder={entity.name}
size={16}
type={entity.avatarType ?? 'rounded'}
/>
{entity.name}
</DropdownMenuCheckableItem>
))}
{entitiesInDropdown?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemContainer>
</DropdownMenu>
);
}

View File

@ -9,7 +9,7 @@ import {
useGetCommentThreadsByTargetsQuery, useGetCommentThreadsByTargetsQuery,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { commentableEntityArrayState } from '../../states/commentableEntityArrayState'; import { commentableEntityArrayState } from '../states/commentableEntityArrayState';
import { CommentThread } from './CommentThread'; import { CommentThread } from './CommentThread';

View File

@ -1,308 +0,0 @@
import { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
autoUpdate,
flip,
offset,
size,
useFloating,
} from '@floating-ui/react';
import { debounce } from 'lodash';
import { v4 } from 'uuid';
import { CommentThreadForDrawer } from '@/comments/types/CommentThreadForDrawer';
import CompanyChip from '@/companies/components/CompanyChip';
import { DropdownMenu } from '@/ui/components/menu/DropdownMenu';
import { DropdownMenuCheckableItem } from '@/ui/components/menu/DropdownMenuCheckableItem';
import { DropdownMenuItem } from '@/ui/components/menu/DropdownMenuItem';
import { DropdownMenuItemContainer } from '@/ui/components/menu/DropdownMenuItemContainer';
import { DropdownMenuSearch } from '@/ui/components/menu/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/components/menu/DropdownMenuSeparator';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { IconArrowUpRight } from '@/ui/icons';
import { Avatar } from '@/users/components/Avatar';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
QueryMode,
SortOrder,
useAddCommentThreadTargetOnCommentThreadMutation,
useRemoveCommentThreadTargetOnCommentThreadMutation,
useSearchCompanyQueryQuery,
} from '~/generated/graphql';
type OwnProps = {
commentThread: CommentThreadForDrawer;
};
const StyledContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: row;
gap: ${(props) => props.theme.spacing(2)};
justify-content: flex-start;
width: 100%;
`;
const StyledLabelContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${(props) => props.theme.spacing(2)};
padding-bottom: ${(props) => props.theme.spacing(2)};
padding-top: ${(props) => props.theme.spacing(2)};
`;
const StyledRelationLabel = styled.div`
color: ${(props) => props.theme.text60};
display: flex;
flex-direction: row;
user-select: none;
`;
const StyledRelationContainer = styled.div`
--horizontal-padding: ${(props) => props.theme.spacing(1)};
--vertical-padding: ${(props) => props.theme.spacing(1.5)};
border: 1px solid transparent;
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: ${(props) => props.theme.spacing(2)};
&:hover {
background-color: ${(props) => props.theme.secondaryBackground};
border: 1px solid ${(props) => props.theme.lightBorder};
}
min-height: calc(32px - 2 * var(--vertical-padding));
overflow: hidden;
padding: var(--vertical-padding) var(--horizontal-padding);
width: calc(100% - 2 * var(--horizontal-padding));
`;
export function CommentThreadRelationPicker({ commentThread }: OwnProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchFilter, setSearchFilter] = useState('');
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
function exitEditMode() {
setIsMenuOpen(false);
setSearchFilter('');
}
useHotkeys(
['esc', 'enter'],
() => {
exitEditMode();
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[exitEditMode],
);
const { refs, floatingStyles } = useFloating({
strategy: 'absolute',
middleware: [offset(), flip(), size()],
whileElementsMounted: autoUpdate,
open: isMenuOpen,
placement: 'bottom-start',
});
useListenClickOutsideArrayOfRef([refs.floating, refs.domReference], () => {
exitEditMode();
});
const theme = useTheme();
const companyIds = commentThread.commentThreadTargets
?.filter((relation) => relation.commentableType === 'Company')
.map((relation) => relation.commentableId);
const { data: selectedCompaniesData } = useSearchCompanyQueryQuery({
variables: {
where: {
id: {
in: companyIds,
},
},
orderBy: {
name: SortOrder.Asc,
},
},
});
const { data: filteredSelectedCompaniesData } = useSearchCompanyQueryQuery({
variables: {
where: {
AND: [
{
name: {
contains: `%${searchFilter}%`,
mode: QueryMode.Insensitive,
},
},
{
id: {
in: companyIds,
},
},
],
},
orderBy: {
name: SortOrder.Asc,
},
},
});
const { data: companiesToSelectData } = useSearchCompanyQueryQuery({
variables: {
where: {
AND: [
{
name: {
contains: `%${searchFilter}%`,
mode: QueryMode.Insensitive,
},
},
{
id: {
notIn: companyIds,
},
},
],
},
limit: 10,
orderBy: {
name: SortOrder.Asc,
},
},
});
function handleFilterChange(event: React.ChangeEvent<HTMLInputElement>) {
debouncedSetSearchFilter(event.currentTarget.value);
}
function handleChangeRelationsClick() {
setIsMenuOpen((isOpen) => !isOpen);
}
const [addCommentThreadTargetOnCommentThread] =
useAddCommentThreadTargetOnCommentThreadMutation({
refetchQueries: ['GetCompanies'],
});
const [removeCommentThreadTargetOnCommentThread] =
useRemoveCommentThreadTargetOnCommentThreadMutation({
refetchQueries: ['GetCompanies'],
});
function handleCheckItemChange(newCheckedValue: boolean, itemId: string) {
if (newCheckedValue) {
addCommentThreadTargetOnCommentThread({
variables: {
commentableEntityId: itemId,
commentableEntityType: CommentableType.Company,
commentThreadId: commentThread.id,
commentThreadTargetCreationDate: new Date().toISOString(),
commentThreadTargetId: v4(),
},
});
} else {
const foundCorrespondingTarget = commentThread.commentThreadTargets?.find(
(target) => target.commentableId === itemId,
);
if (foundCorrespondingTarget) {
removeCommentThreadTargetOnCommentThread({
variables: {
commentThreadId: commentThread.id,
commentThreadTargetId: foundCorrespondingTarget.id,
},
});
}
}
}
const selectedCompanies = selectedCompaniesData?.searchResults ?? [];
const filteredSelectedCompanies =
filteredSelectedCompaniesData?.searchResults ?? [];
const companiesToSelect = companiesToSelectData?.searchResults ?? [];
const companiesInDropdown = [
...filteredSelectedCompanies,
...companiesToSelect,
];
return (
<StyledContainer>
<StyledLabelContainer>
<IconArrowUpRight size={16} color={theme.text40} />
<StyledRelationLabel>Relations</StyledRelationLabel>
</StyledLabelContainer>
<StyledRelationContainer
ref={refs.setReference}
onClick={handleChangeRelationsClick}
>
{selectedCompanies?.map((company) => (
<CompanyChip
key={company.id}
name={company.name}
picture={getLogoUrlFromDomainName(company.domainName)}
/>
))}
</StyledRelationContainer>
{isMenuOpen && (
<DropdownMenu ref={refs.setFloating} style={floatingStyles}>
<DropdownMenuSearch
value={searchFilter}
onChange={handleFilterChange}
/>
<DropdownMenuSeparator />
<DropdownMenuItemContainer>
{companiesInDropdown?.map((company) => (
<DropdownMenuCheckableItem
key={company.id}
checked={
selectedCompanies
?.map((selectedCompany) => selectedCompany.id)
?.includes(company.id) ?? false
}
onChange={(newCheckedValue) =>
handleCheckItemChange(newCheckedValue, company.id)
}
>
<Avatar
avatarUrl={getLogoUrlFromDomainName(company.domainName)}
placeholder={company.name}
size={16}
/>
{company.name}
</DropdownMenuCheckableItem>
))}
{companiesInDropdown?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemContainer>
</DropdownMenu>
)}
</StyledContainer>
);
}

View File

@ -0,0 +1,55 @@
import { v4 } from 'uuid';
import {
useAddCommentThreadTargetOnCommentThreadMutation,
useRemoveCommentThreadTargetOnCommentThreadMutation,
} from '~/generated/graphql';
import { EntityForSelect } from '../components/MultipleEntitySelect';
import { CommentThreadForDrawer } from '../types/CommentThreadForDrawer';
export function useHandleCheckableCommentThreadTargetChange({
commentThread,
}: {
commentThread: CommentThreadForDrawer;
}) {
const [addCommentThreadTargetOnCommentThread] =
useAddCommentThreadTargetOnCommentThreadMutation({
refetchQueries: ['GetCompanies', 'GetPeople'],
});
const [removeCommentThreadTargetOnCommentThread] =
useRemoveCommentThreadTargetOnCommentThreadMutation({
refetchQueries: ['GetCompanies', 'GetPeople'],
});
return function handleCheckItemChange(
newCheckedValue: boolean,
entity: EntityForSelect,
) {
if (newCheckedValue) {
addCommentThreadTargetOnCommentThread({
variables: {
commentableEntityId: entity.id,
commentableEntityType: entity.entityType,
commentThreadId: commentThread.id,
commentThreadTargetCreationDate: new Date().toISOString(),
commentThreadTargetId: v4(),
},
});
} else {
const foundCorrespondingTarget = commentThread.commentThreadTargets?.find(
(target) => target.commentableId === entity.id,
);
if (foundCorrespondingTarget) {
removeCommentThreadTargetOnCommentThread({
variables: {
commentThreadId: commentThread.id,
commentThreadTargetId: foundCorrespondingTarget.id,
},
});
}
}
};
}

View File

@ -1,4 +1,4 @@
import { CellCommentChip } from '@/comments/components/comments/CellCommentChip'; import { CellCommentChip } from '@/comments/components/CellCommentChip';
import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer'; import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer';
import EditableChip from '@/ui/components/editable-cell/types/EditableChip'; import EditableChip from '@/ui/components/editable-cell/types/EditableChip';
import { getLogoUrlFromDomainName } from '@/utils/utils'; import { getLogoUrlFromDomainName } from '@/utils/utils';

View File

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { CellCommentChip } from '@/comments/components/comments/CellCommentChip'; import { CellCommentChip } from '@/comments/components/CellCommentChip';
import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer'; import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer';
import { EditableDoubleText } from '@/ui/components/editable-cell/types/EditableDoubleText'; import { EditableDoubleText } from '@/ui/components/editable-cell/types/EditableDoubleText';
import { CommentableType } from '~/generated/graphql'; import { CommentableType } from '~/generated/graphql';

View File

@ -7,8 +7,16 @@ import { AnyEntity, UnknownType } from '@/utils/interfaces/generic.interface';
import { SearchConfigType } from '../interfaces/interface'; import { SearchConfigType } from '../interfaces/interface';
export const SEARCH_PEOPLE_QUERY = gql` export const SEARCH_PEOPLE_QUERY = gql`
query SearchPeopleQuery($where: PersonWhereInput, $limit: Int) { query SearchPeople(
searchResults: findManyPerson(where: $where, take: $limit) { $where: PersonWhereInput
$limit: Int
$orderBy: [PersonOrderByWithRelationInput!]
) {
searchResults: findManyPerson(
where: $where
take: $limit
orderBy: $orderBy
) {
id id
phone phone
email email
@ -21,7 +29,7 @@ export const SEARCH_PEOPLE_QUERY = gql`
`; `;
export const SEARCH_USER_QUERY = gql` export const SEARCH_USER_QUERY = gql`
query SearchUserQuery($where: UserWhereInput, $limit: Int) { query SearchUser($where: UserWhereInput, $limit: Int) {
searchResults: findManyUser(where: $where, take: $limit) { searchResults: findManyUser(where: $where, take: $limit) {
id id
email email
@ -39,7 +47,7 @@ export const EMPTY_QUERY = gql`
`; `;
export const SEARCH_COMPANY_QUERY = gql` export const SEARCH_COMPANY_QUERY = gql`
query SearchCompanyQuery( query SearchCompany(
$where: CompanyWhereInput $where: CompanyWhereInput
$limit: Int $limit: Int
$orderBy: [CompanyOrderByWithRelationInput!] $orderBy: [CompanyOrderByWithRelationInput!]

View File

@ -17,6 +17,4 @@ export const DropdownMenu = styled.div`
height: fit-content; height: fit-content;
width: 200px; width: 200px;
z-index: ${(props) => props.theme.lastLayerZIndex};
`; `;

View File

@ -0,0 +1,145 @@
import * as Apollo from '@apollo/client';
import {
EntitiesForMultipleEntitySelect,
EntityForSelect,
} from '@/comments/components/MultipleEntitySelect';
import {
Exact,
InputMaybe,
QueryMode,
Scalars,
SortOrder,
} from '~/generated/graphql';
type SelectStringKeys<T> = NonNullable<
{
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T]
>;
type ExtractEntityTypeFromQueryResponse<T> = T extends {
searchResults: Array<infer U>;
}
? U
: never;
const DEFAULT_SEARCH_REQUEST_LIMIT = 10;
export function useFilteredSearchEntityQuery<
EntityType extends ExtractEntityTypeFromQueryResponse<QueryResponseForExtract> & {
id: string;
},
EntityStringField extends SelectStringKeys<EntityType>,
OrderByField extends EntityStringField,
SearchOnField extends EntityStringField,
QueryResponseForExtract,
QueryResponse extends {
searchResults: EntityType[];
},
EntityWhereInput,
EntityOrderByWithRelationInput,
QueryVariables extends Exact<{
where?: InputMaybe<EntityWhereInput>;
limit?: InputMaybe<Scalars['Int']>;
orderBy?: InputMaybe<
Array<EntityOrderByWithRelationInput> | EntityOrderByWithRelationInput
>;
}>,
>({
queryHook,
searchOnFields,
orderByField,
sortOrder = SortOrder.Asc,
selectedIds,
mappingFunction,
limit,
searchFilter, // TODO: put in a scoped recoil state
}: {
queryHook: (
queryOptions?: Apollo.QueryHookOptions<
QueryResponseForExtract,
QueryVariables
>,
) => Apollo.QueryResult<QueryResponse, QueryVariables>;
searchOnFields: SearchOnField[];
orderByField: OrderByField;
sortOrder?: SortOrder;
selectedIds: string[];
mappingFunction: (entity: EntityType) => EntityForSelect;
limit?: number;
searchFilter: string;
}): EntitiesForMultipleEntitySelect {
const { data: selectedEntitiesData } = queryHook({
variables: {
where: {
id: {
in: selectedIds,
},
},
orderBy: {
[orderByField]: sortOrder,
},
} as QueryVariables,
});
const searchFilterByField = searchOnFields.map((field) => ({
[field]: {
contains: `%${searchFilter}%`,
mode: QueryMode.Insensitive,
},
}));
const { data: filteredSelectedEntitiesData } = queryHook({
variables: {
where: {
AND: [
{
OR: searchFilterByField,
},
{
id: {
in: selectedIds,
},
},
],
},
orderBy: {
[orderByField]: sortOrder,
},
} as QueryVariables,
});
const { data: entitiesToSelectData } = queryHook({
variables: {
where: {
AND: [
{
OR: searchFilterByField,
},
{
id: {
notIn: selectedIds,
},
},
],
},
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
orderBy: {
[orderByField]: sortOrder,
},
} as QueryVariables,
});
return {
selectedEntities: (selectedEntitiesData?.searchResults ?? []).map(
mappingFunction,
),
filteredSelectedEntities: (
filteredSelectedEntitiesData?.searchResults ?? []
).map(mappingFunction),
entitiesToSelect: (entitiesToSelectData?.searchResults ?? []).map(
mappingFunction,
),
};
}

View File

@ -1,7 +1,7 @@
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { RightDrawerComments } from '@/comments/components/comments/RightDrawerComments'; import { RightDrawerComments } from '@/comments/components/RightDrawerComments';
import { RightDrawerCreateCommentThread } from '@/comments/components/comments/RightDrawerCreateCommentThread'; import { RightDrawerCreateCommentThread } from '@/comments/components/RightDrawerCreateCommentThread';
import { isDefined } from '@/utils/type-guards/isDefined'; import { isDefined } from '@/utils/type-guards/isDefined';
import { rightDrawerPageState } from '../states/rightDrawerPageState'; import { rightDrawerPageState } from '../states/rightDrawerPageState';

View File

@ -0,0 +1,10 @@
import { EntityForSelect } from '@/comments/components/MultipleEntitySelect';
export function flatMapAndSortEntityForSelectArrayOfArrayByName(
entityForSelectArray: EntityForSelect[][],
) {
const sortByName = (a: EntityForSelect, b: EntityForSelect) =>
a.name.localeCompare(b.name);
return entityForSelectArray.flatMap((entity) => entity).sort(sortByName);
}

View File

@ -2,11 +2,13 @@ import styled from '@emotion/styled';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString'; import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
export type AvatarType = 'squared' | 'rounded';
type OwnProps = { type OwnProps = {
avatarUrl: string | null | undefined; avatarUrl: string | null | undefined;
size: number; size: number;
placeholder: string; placeholder: string;
type?: 'squared' | 'rounded'; type?: AvatarType;
}; };
export const StyledAvatar = styled.div<Omit<OwnProps, 'placeholder'>>` export const StyledAvatar = styled.div<Omit<OwnProps, 'placeholder'>>`

View File

@ -27,7 +27,7 @@ export const mocks = [
}), }),
); );
}), }),
graphql.query('SearchUserQuery', (req, res, ctx) => { graphql.query('SearchUser', (req, res, ctx) => {
const returnedMockedData = filterAndSortData<GraphqlQueryUser>( const returnedMockedData = filterAndSortData<GraphqlQueryUser>(
mockedUsersData, mockedUsersData,
req.variables.where, req.variables.where,

View File

@ -23,7 +23,7 @@ export const graphqlMocks = [
}), }),
); );
}), }),
graphql.query('SearchCompanyQuery', (req, res, ctx) => { graphql.query('SearchCompany', (req, res, ctx) => {
const returnedMockedData = filterAndSortData<GraphqlQueryCompany>( const returnedMockedData = filterAndSortData<GraphqlQueryCompany>(
mockedCompaniesData, mockedCompaniesData,
req.variables.where, req.variables.where,
@ -36,7 +36,7 @@ export const graphqlMocks = [
}), }),
); );
}), }),
graphql.query('SearchUserQuery', (req, res, ctx) => { graphql.query('SearchUser', (req, res, ctx) => {
const returnedMockedData = filterAndSortData<GraphqlQueryUser>( const returnedMockedData = filterAndSortData<GraphqlQueryUser>(
mockedUsersData, mockedUsersData,
req.variables.where, req.variables.where,