diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 307ebe0e0..d26bdec1b 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -1675,35 +1675,36 @@ export type DeletePeopleMutationVariables = Exact<{ export type DeletePeopleMutation = { __typename?: 'Mutation', deleteManyPerson: { __typename?: 'AffectedRows', count: number } }; -export type SearchPeopleQueryQueryVariables = Exact<{ +export type SearchPeopleQueryVariables = Exact<{ where?: InputMaybe; limit?: InputMaybe; + orderBy?: InputMaybe | 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; limit?: InputMaybe; }>; -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 EmptyQueryQuery = { __typename?: 'Query', searchResults: Array<{ __typename?: 'User', id: string }> }; -export type SearchCompanyQueryQueryVariables = Exact<{ +export type SearchCompanyQueryVariables = Exact<{ where?: InputMaybe; limit?: InputMaybe; orderBy?: InputMaybe | 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<{ uuid?: InputMaybe; @@ -2433,9 +2434,9 @@ export function useDeletePeopleMutation(baseOptions?: Apollo.MutationHookOptions export type DeletePeopleMutationHookResult = ReturnType; export type DeletePeopleMutationResult = Apollo.MutationResult; export type DeletePeopleMutationOptions = Apollo.BaseMutationOptions; -export const SearchPeopleQueryDocument = gql` - query SearchPeopleQuery($where: PersonWhereInput, $limit: Int) { - searchResults: findManyPerson(where: $where, take: $limit) { +export const SearchPeopleDocument = gql` + query SearchPeople($where: PersonWhereInput, $limit: Int, $orderBy: [PersonOrderByWithRelationInput!]) { + searchResults: findManyPerson(where: $where, take: $limit, orderBy: $orderBy) { id phone 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. - * When your component renders, `useSearchPeopleQueryQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useSearchPeopleQuery` and pass it any options that fit your needs. + * 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. * * @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 - * const { data, loading, error } = useSearchPeopleQueryQuery({ + * const { data, loading, error } = useSearchPeopleQuery({ * variables: { * where: // value for 'where' * limit: // value for 'limit' + * orderBy: // value for 'orderBy' * }, * }); */ -export function useSearchPeopleQueryQuery(baseOptions?: Apollo.QueryHookOptions) { +export function useSearchPeopleQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(SearchPeopleQueryDocument, options); + return Apollo.useQuery(SearchPeopleDocument, options); } -export function useSearchPeopleQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useSearchPeopleLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(SearchPeopleQueryDocument, options); + return Apollo.useLazyQuery(SearchPeopleDocument, options); } -export type SearchPeopleQueryQueryHookResult = ReturnType; -export type SearchPeopleQueryLazyQueryHookResult = ReturnType; -export type SearchPeopleQueryQueryResult = Apollo.QueryResult; -export const SearchUserQueryDocument = gql` - query SearchUserQuery($where: UserWhereInput, $limit: Int) { +export type SearchPeopleQueryHookResult = ReturnType; +export type SearchPeopleLazyQueryHookResult = ReturnType; +export type SearchPeopleQueryResult = Apollo.QueryResult; +export const SearchUserDocument = gql` + query SearchUser($where: UserWhereInput, $limit: Int) { searchResults: findManyUser(where: $where, take: $limit) { id 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. - * When your component renders, `useSearchUserQueryQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useSearchUserQuery` and pass it any options that fit your needs. + * 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. * * @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 - * const { data, loading, error } = useSearchUserQueryQuery({ + * const { data, loading, error } = useSearchUserQuery({ * variables: { * where: // value for 'where' * limit: // value for 'limit' * }, * }); */ -export function useSearchUserQueryQuery(baseOptions?: Apollo.QueryHookOptions) { +export function useSearchUserQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(SearchUserQueryDocument, options); + return Apollo.useQuery(SearchUserDocument, options); } -export function useSearchUserQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useSearchUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(SearchUserQueryDocument, options); + return Apollo.useLazyQuery(SearchUserDocument, options); } -export type SearchUserQueryQueryHookResult = ReturnType; -export type SearchUserQueryLazyQueryHookResult = ReturnType; -export type SearchUserQueryQueryResult = Apollo.QueryResult; +export type SearchUserQueryHookResult = ReturnType; +export type SearchUserLazyQueryHookResult = ReturnType; +export type SearchUserQueryResult = Apollo.QueryResult; export const EmptyQueryDocument = gql` query EmptyQuery { searchResults: findManyUser { @@ -2547,8 +2549,8 @@ export function useEmptyQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions export type EmptyQueryQueryHookResult = ReturnType; export type EmptyQueryLazyQueryHookResult = ReturnType; export type EmptyQueryQueryResult = Apollo.QueryResult; -export const SearchCompanyQueryDocument = gql` - query SearchCompanyQuery($where: CompanyWhereInput, $limit: Int, $orderBy: [CompanyOrderByWithRelationInput!]) { +export const SearchCompanyDocument = gql` + query SearchCompany($where: CompanyWhereInput, $limit: Int, $orderBy: [CompanyOrderByWithRelationInput!]) { searchResults: findManyCompany(where: $where, take: $limit, orderBy: $orderBy) { id 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. - * When your component renders, `useSearchCompanyQueryQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useSearchCompanyQuery` and pass it any options that fit your needs. + * 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. * * @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 - * const { data, loading, error } = useSearchCompanyQueryQuery({ + * const { data, loading, error } = useSearchCompanyQuery({ * variables: { * where: // value for 'where' * limit: // value for 'limit' @@ -2575,17 +2577,17 @@ export const SearchCompanyQueryDocument = gql` * }, * }); */ -export function useSearchCompanyQueryQuery(baseOptions?: Apollo.QueryHookOptions) { +export function useSearchCompanyQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(SearchCompanyQueryDocument, options); + return Apollo.useQuery(SearchCompanyDocument, options); } -export function useSearchCompanyQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useSearchCompanyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(SearchCompanyQueryDocument, options); + return Apollo.useLazyQuery(SearchCompanyDocument, options); } -export type SearchCompanyQueryQueryHookResult = ReturnType; -export type SearchCompanyQueryLazyQueryHookResult = ReturnType; -export type SearchCompanyQueryQueryResult = Apollo.QueryResult; +export type SearchCompanyQueryHookResult = ReturnType; +export type SearchCompanyLazyQueryHookResult = ReturnType; +export type SearchCompanyQueryResult = Apollo.QueryResult; export const GetCurrentUserDocument = gql` query GetCurrentUser($uuid: String) { users: findManyUser(where: {id: {equals: $uuid}}) { diff --git a/front/src/modules/comments/components/comments/CellCommentChip.tsx b/front/src/modules/comments/components/CellCommentChip.tsx similarity index 100% rename from front/src/modules/comments/components/comments/CellCommentChip.tsx rename to front/src/modules/comments/components/CellCommentChip.tsx diff --git a/front/src/modules/comments/components/comments/CommentChip.tsx b/front/src/modules/comments/components/CommentChip.tsx similarity index 100% rename from front/src/modules/comments/components/comments/CommentChip.tsx rename to front/src/modules/comments/components/CommentChip.tsx diff --git a/front/src/modules/comments/components/comments/CommentHeader.tsx b/front/src/modules/comments/components/CommentHeader.tsx similarity index 100% rename from front/src/modules/comments/components/comments/CommentHeader.tsx rename to front/src/modules/comments/components/CommentHeader.tsx diff --git a/front/src/modules/comments/components/comments/CommentThread.tsx b/front/src/modules/comments/components/CommentThread.tsx similarity index 100% rename from front/src/modules/comments/components/comments/CommentThread.tsx rename to front/src/modules/comments/components/CommentThread.tsx diff --git a/front/src/modules/comments/components/comments/CommentThreadCreateMode.tsx b/front/src/modules/comments/components/CommentThreadCreateMode.tsx similarity index 100% rename from front/src/modules/comments/components/comments/CommentThreadCreateMode.tsx rename to front/src/modules/comments/components/CommentThreadCreateMode.tsx diff --git a/front/src/modules/comments/components/comments/CommentThreadItem.tsx b/front/src/modules/comments/components/CommentThreadItem.tsx similarity index 100% rename from front/src/modules/comments/components/comments/CommentThreadItem.tsx rename to front/src/modules/comments/components/CommentThreadItem.tsx diff --git a/front/src/modules/comments/components/CommentThreadRelationPicker.tsx b/front/src/modules/comments/components/CommentThreadRelationPicker.tsx new file mode 100644 index 000000000..2f9f2cf68 --- /dev/null +++ b/front/src/modules/comments/components/CommentThreadRelationPicker.tsx @@ -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 ( + + + + Relations + + + {selectedEntities?.map((entity) => + entity.entityType === CommentableType.Company ? ( + + ) : ( + + ), + )} + + {isMenuOpen && ( + + + + )} + + ); +} diff --git a/front/src/modules/comments/components/MultipleEntitySelect.tsx b/front/src/modules/comments/components/MultipleEntitySelect.tsx new file mode 100644 index 000000000..92874452a --- /dev/null +++ b/front/src/modules/comments/components/MultipleEntitySelect.tsx @@ -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) { + debouncedSetSearchFilter(event.currentTarget.value); + onSearchFilterChange(event.currentTarget.value); + } + + const entitiesInDropdown = [ + ...(entities.filteredSelectedEntities ?? []), + ...(entities.entitiesToSelect ?? []), + ]; + + return ( + + + + + {entitiesInDropdown?.map((entity) => ( + selectedEntity.id) + ?.includes(entity.id) ?? false + } + onChange={(newCheckedValue) => + onItemCheckChange(newCheckedValue, entity) + } + > + + {entity.name} + + ))} + {entitiesInDropdown?.length === 0 && ( + No result + )} + + + ); +} diff --git a/front/src/modules/comments/components/comments/RightDrawerComments.tsx b/front/src/modules/comments/components/RightDrawerComments.tsx similarity index 93% rename from front/src/modules/comments/components/comments/RightDrawerComments.tsx rename to front/src/modules/comments/components/RightDrawerComments.tsx index daa6bab3c..804477f8b 100644 --- a/front/src/modules/comments/components/comments/RightDrawerComments.tsx +++ b/front/src/modules/comments/components/RightDrawerComments.tsx @@ -9,7 +9,7 @@ import { useGetCommentThreadsByTargetsQuery, } from '~/generated/graphql'; -import { commentableEntityArrayState } from '../../states/commentableEntityArrayState'; +import { commentableEntityArrayState } from '../states/commentableEntityArrayState'; import { CommentThread } from './CommentThread'; diff --git a/front/src/modules/comments/components/comments/RightDrawerCreateCommentThread.tsx b/front/src/modules/comments/components/RightDrawerCreateCommentThread.tsx similarity index 100% rename from front/src/modules/comments/components/comments/RightDrawerCreateCommentThread.tsx rename to front/src/modules/comments/components/RightDrawerCreateCommentThread.tsx diff --git a/front/src/modules/comments/components/comments/__stories__/CommentChip.stories.tsx b/front/src/modules/comments/components/__stories__/CommentChip.stories.tsx similarity index 100% rename from front/src/modules/comments/components/comments/__stories__/CommentChip.stories.tsx rename to front/src/modules/comments/components/__stories__/CommentChip.stories.tsx diff --git a/front/src/modules/comments/components/comments/__stories__/CommentHeader.stories.tsx b/front/src/modules/comments/components/__stories__/CommentHeader.stories.tsx similarity index 100% rename from front/src/modules/comments/components/comments/__stories__/CommentHeader.stories.tsx rename to front/src/modules/comments/components/__stories__/CommentHeader.stories.tsx diff --git a/front/src/modules/comments/components/comments/__stories__/CommentThreadRelationPicker.stories.tsx b/front/src/modules/comments/components/__stories__/CommentThreadRelationPicker.stories.tsx similarity index 100% rename from front/src/modules/comments/components/comments/__stories__/CommentThreadRelationPicker.stories.tsx rename to front/src/modules/comments/components/__stories__/CommentThreadRelationPicker.stories.tsx diff --git a/front/src/modules/comments/components/comments/CommentThreadRelationPicker.tsx b/front/src/modules/comments/components/comments/CommentThreadRelationPicker.tsx deleted file mode 100644 index 146ac337a..000000000 --- a/front/src/modules/comments/components/comments/CommentThreadRelationPicker.tsx +++ /dev/null @@ -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) { - 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 ( - - - - Relations - - - {selectedCompanies?.map((company) => ( - - ))} - - {isMenuOpen && ( - - - - - {companiesInDropdown?.map((company) => ( - selectedCompany.id) - ?.includes(company.id) ?? false - } - onChange={(newCheckedValue) => - handleCheckItemChange(newCheckedValue, company.id) - } - > - - {company.name} - - ))} - {companiesInDropdown?.length === 0 && ( - No result - )} - - - )} - - ); -} diff --git a/front/src/modules/comments/hooks/useHandleCheckableCommentThreadTargetChange.ts b/front/src/modules/comments/hooks/useHandleCheckableCommentThreadTargetChange.ts new file mode 100644 index 000000000..78aced722 --- /dev/null +++ b/front/src/modules/comments/hooks/useHandleCheckableCommentThreadTargetChange.ts @@ -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, + }, + }); + } + } + }; +} diff --git a/front/src/modules/companies/components/CompanyEditableNameCell.tsx b/front/src/modules/companies/components/CompanyEditableNameCell.tsx index 9508449ef..68426f01a 100644 --- a/front/src/modules/companies/components/CompanyEditableNameCell.tsx +++ b/front/src/modules/companies/components/CompanyEditableNameCell.tsx @@ -1,4 +1,4 @@ -import { CellCommentChip } from '@/comments/components/comments/CellCommentChip'; +import { CellCommentChip } from '@/comments/components/CellCommentChip'; import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer'; import EditableChip from '@/ui/components/editable-cell/types/EditableChip'; import { getLogoUrlFromDomainName } from '@/utils/utils'; diff --git a/front/src/modules/people/components/EditablePeopleFullName.tsx b/front/src/modules/people/components/EditablePeopleFullName.tsx index e44e7cece..dccc66762 100644 --- a/front/src/modules/people/components/EditablePeopleFullName.tsx +++ b/front/src/modules/people/components/EditablePeopleFullName.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; 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 { EditableDoubleText } from '@/ui/components/editable-cell/types/EditableDoubleText'; import { CommentableType } from '~/generated/graphql'; diff --git a/front/src/modules/search/services/search.ts b/front/src/modules/search/services/search.ts index 7ef9651a5..b0d327469 100644 --- a/front/src/modules/search/services/search.ts +++ b/front/src/modules/search/services/search.ts @@ -7,8 +7,16 @@ import { AnyEntity, UnknownType } from '@/utils/interfaces/generic.interface'; import { SearchConfigType } from '../interfaces/interface'; export const SEARCH_PEOPLE_QUERY = gql` - query SearchPeopleQuery($where: PersonWhereInput, $limit: Int) { - searchResults: findManyPerson(where: $where, take: $limit) { + query SearchPeople( + $where: PersonWhereInput + $limit: Int + $orderBy: [PersonOrderByWithRelationInput!] + ) { + searchResults: findManyPerson( + where: $where + take: $limit + orderBy: $orderBy + ) { id phone email @@ -21,7 +29,7 @@ export const SEARCH_PEOPLE_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) { id email @@ -39,7 +47,7 @@ export const EMPTY_QUERY = gql` `; export const SEARCH_COMPANY_QUERY = gql` - query SearchCompanyQuery( + query SearchCompany( $where: CompanyWhereInput $limit: Int $orderBy: [CompanyOrderByWithRelationInput!] diff --git a/front/src/modules/ui/components/menu/DropdownMenu.tsx b/front/src/modules/ui/components/menu/DropdownMenu.tsx index 8e2bb7103..1b83a562e 100644 --- a/front/src/modules/ui/components/menu/DropdownMenu.tsx +++ b/front/src/modules/ui/components/menu/DropdownMenu.tsx @@ -17,6 +17,4 @@ export const DropdownMenu = styled.div` height: fit-content; width: 200px; - - z-index: ${(props) => props.theme.lastLayerZIndex}; `; diff --git a/front/src/modules/ui/hooks/menu/useFilteredSearchEntityQuery.ts b/front/src/modules/ui/hooks/menu/useFilteredSearchEntityQuery.ts new file mode 100644 index 000000000..2cfe91dcd --- /dev/null +++ b/front/src/modules/ui/hooks/menu/useFilteredSearchEntityQuery.ts @@ -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 = NonNullable< + { + [K in keyof T]: T[K] extends string ? K : never; + }[keyof T] +>; + +type ExtractEntityTypeFromQueryResponse = T extends { + searchResults: Array; +} + ? U + : never; + +const DEFAULT_SEARCH_REQUEST_LIMIT = 10; + +export function useFilteredSearchEntityQuery< + EntityType extends ExtractEntityTypeFromQueryResponse & { + id: string; + }, + EntityStringField extends SelectStringKeys, + OrderByField extends EntityStringField, + SearchOnField extends EntityStringField, + QueryResponseForExtract, + QueryResponse extends { + searchResults: EntityType[]; + }, + EntityWhereInput, + EntityOrderByWithRelationInput, + QueryVariables extends Exact<{ + where?: InputMaybe; + limit?: InputMaybe; + orderBy?: InputMaybe< + Array | 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; + 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, + ), + }; +} diff --git a/front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx b/front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx index 646da7e02..b7dafb48b 100644 --- a/front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx +++ b/front/src/modules/ui/layout/right-drawer/components/RightDrawerRouter.tsx @@ -1,7 +1,7 @@ import { useRecoilState } from 'recoil'; -import { RightDrawerComments } from '@/comments/components/comments/RightDrawerComments'; -import { RightDrawerCreateCommentThread } from '@/comments/components/comments/RightDrawerCreateCommentThread'; +import { RightDrawerComments } from '@/comments/components/RightDrawerComments'; +import { RightDrawerCreateCommentThread } from '@/comments/components/RightDrawerCreateCommentThread'; import { isDefined } from '@/utils/type-guards/isDefined'; import { rightDrawerPageState } from '../states/rightDrawerPageState'; diff --git a/front/src/modules/ui/utils/flatMapAndSortEntityForSelectArrayByName.ts b/front/src/modules/ui/utils/flatMapAndSortEntityForSelectArrayByName.ts new file mode 100644 index 000000000..896b1c7de --- /dev/null +++ b/front/src/modules/ui/utils/flatMapAndSortEntityForSelectArrayByName.ts @@ -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); +} diff --git a/front/src/modules/users/components/Avatar.tsx b/front/src/modules/users/components/Avatar.tsx index 77a0fab30..317e4637b 100644 --- a/front/src/modules/users/components/Avatar.tsx +++ b/front/src/modules/users/components/Avatar.tsx @@ -2,11 +2,13 @@ import styled from '@emotion/styled'; import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString'; +export type AvatarType = 'squared' | 'rounded'; + type OwnProps = { avatarUrl: string | null | undefined; size: number; placeholder: string; - type?: 'squared' | 'rounded'; + type?: AvatarType; }; export const StyledAvatar = styled.div>` diff --git a/front/src/pages/companies/__stories__/shared.tsx b/front/src/pages/companies/__stories__/shared.tsx index d50e7fb88..a7dcb2b3f 100644 --- a/front/src/pages/companies/__stories__/shared.tsx +++ b/front/src/pages/companies/__stories__/shared.tsx @@ -27,7 +27,7 @@ export const mocks = [ }), ); }), - graphql.query('SearchUserQuery', (req, res, ctx) => { + graphql.query('SearchUser', (req, res, ctx) => { const returnedMockedData = filterAndSortData( mockedUsersData, req.variables.where, diff --git a/front/src/testing/graphqlMocks.ts b/front/src/testing/graphqlMocks.ts index 9f7987a04..95cf6e661 100644 --- a/front/src/testing/graphqlMocks.ts +++ b/front/src/testing/graphqlMocks.ts @@ -23,7 +23,7 @@ export const graphqlMocks = [ }), ); }), - graphql.query('SearchCompanyQuery', (req, res, ctx) => { + graphql.query('SearchCompany', (req, res, ctx) => { const returnedMockedData = filterAndSortData( mockedCompaniesData, 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( mockedUsersData, req.variables.where,