diff --git a/front/package.json b/front/package.json index d2436696b..d911ab49e 100644 --- a/front/package.json +++ b/front/package.json @@ -31,6 +31,7 @@ "react-textarea-autosize": "^8.4.1", "react-tooltip": "^5.13.1", "recoil": "^0.7.7", + "scroll-into-view": "^1.16.2", "uuid": "^9.0.0", "web-vitals": "^2.1.4" }, @@ -108,6 +109,7 @@ "@types/jest": "^27.5.2", "@types/luxon": "^3.3.0", "@types/react-datepicker": "^4.11.2", + "@types/scroll-into-view": "^1.16.0", "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.45.0", "babel-plugin-named-exports-order": "^0.0.2", diff --git a/front/src/modules/opportunities/components/Board.tsx b/front/src/modules/opportunities/components/Board.tsx index 8ca9fe157..d4f54755d 100644 --- a/front/src/modules/opportunities/components/Board.tsx +++ b/front/src/modules/opportunities/components/Board.tsx @@ -75,7 +75,6 @@ export const Board = ({ [board, onUpdate], ); - console.log('board', board); return ( diff --git a/front/src/modules/people/components/PeopleCompanyCell.tsx b/front/src/modules/people/components/PeopleCompanyCell.tsx index be3cf9766..afedcaa09 100644 --- a/front/src/modules/people/components/PeopleCompanyCell.tsx +++ b/front/src/modules/people/components/PeopleCompanyCell.tsx @@ -1,112 +1,33 @@ -import { useState } from 'react'; -import { v4 } from 'uuid'; - -import CompanyChip, { - CompanyChipPropsType, -} from '@/companies/components/CompanyChip'; -import { SearchConfigType } from '@/search/interfaces/interface'; -import { SEARCH_COMPANY_QUERY } from '@/search/services/search'; -import { EditableRelation } from '@/ui/components/editable-cell/types/EditableRelation'; -import { logError } from '@/utils/logs/logError'; +import CompanyChip from '@/companies/components/CompanyChip'; +import { EditableCellV2 } from '@/ui/components/editable-cell/EditableCellV2'; +import { isCreateModeScopedState } from '@/ui/components/editable-cell/states/isCreateModeScopedState'; +import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; import { getLogoUrlFromDomainName } from '@/utils/utils'; -import { - Company, - Person, - QueryMode, - useInsertCompanyMutation, - useUpdatePeopleMutation, -} from '~/generated/graphql'; +import { Company, Person } from '~/generated/graphql'; import { PeopleCompanyCreateCell } from './PeopleCompanyCreateCell'; +import { PeopleCompanyPicker } from './PeopleCompanyPicker'; export type OwnProps = { people: Pick & { - company?: Pick | null; + company?: Pick | null; }; }; export function PeopleCompanyCell({ people }: OwnProps) { - const [isCreating, setIsCreating] = useState(false); - const [insertCompany] = useInsertCompanyMutation(); - const [updatePeople] = useUpdatePeopleMutation(); - const [initialCompanyName, setInitialCompanyName] = useState(''); - - async function handleCompanyCreate( - companyName: string, - companyDomainName: string, - ) { - const newCompanyId = v4(); - - try { - await insertCompany({ - variables: { - id: newCompanyId, - name: companyName, - domainName: companyDomainName, - address: '', - createdAt: new Date().toISOString(), - }, - }); - - await updatePeople({ - variables: { - ...people, - companyId: newCompanyId, - }, - }); - } catch (error) { - // TODO: handle error better - logError(error); - } - - setIsCreating(false); - } - - // TODO: should be replaced with search context - function handleChangeSearchInput(searchInput: string) { - setInitialCompanyName(searchInput); - } + const [isCreating] = useRecoilScopedState(isCreateModeScopedState); return isCreating ? ( - + ) : ( - - relation={people.company} - searchPlaceholder="Company" - ChipComponent={CompanyChip} - chipComponentPropsMapper={(company): CompanyChipPropsType => { - return { - name: company.name || '', - picture: getLogoUrlFromDomainName(company.domainName), - }; - }} - onChange={async (relation) => { - await updatePeople({ - variables: { - ...people, - companyId: relation.id, - }, - }); - }} - onChangeSearchInput={handleChangeSearchInput} - searchConfig={ - { - query: SEARCH_COMPANY_QUERY, - template: (searchInput: string) => ({ - name: { contains: `%${searchInput}%`, mode: QueryMode.Insensitive }, - }), - resultMapper: (company) => ({ - render: (company: any) => company.name, - value: company, - }), - } satisfies SearchConfigType + } + nonEditModeContent={ + } - onCreate={() => { - setIsCreating(true); - }} /> ); } diff --git a/front/src/modules/people/components/PeopleCompanyCreateCell.tsx b/front/src/modules/people/components/PeopleCompanyCreateCell.tsx index 3a8de76e5..ebc2dd3ea 100644 --- a/front/src/modules/people/components/PeopleCompanyCreateCell.tsx +++ b/front/src/modules/people/components/PeopleCompanyCreateCell.tsx @@ -1,44 +1,90 @@ import { useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; +import { v4 } from 'uuid'; +import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState'; +import { isCreateModeScopedState } from '@/ui/components/editable-cell/states/isCreateModeScopedState'; import { DoubleTextInput } from '@/ui/components/inputs/DoubleTextInput'; import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef'; +import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; +import { logError } from '@/utils/logs/logError'; +import { + Person, + useInsertCompanyMutation, + useUpdatePeopleMutation, +} from '~/generated/graphql'; type OwnProps = { - initialCompanyName: string; - onCreate: (companyName: string, companyDomainName: string) => void; + people: Pick; }; -export function PeopleCompanyCreateCell({ - initialCompanyName, - onCreate, -}: OwnProps) { - const [companyName, setCompanyName] = useState(initialCompanyName); +export function PeopleCompanyCreateCell({ people }: OwnProps) { + const [, setIsCreating] = useRecoilScopedState(isCreateModeScopedState); + + const [currentSearchFilter] = useRecoilScopedState( + relationPickerSearchFilterScopedState, + ); + + const [companyName, setCompanyName] = useState(currentSearchFilter); + const [companyDomainName, setCompanyDomainName] = useState(''); + const [insertCompany] = useInsertCompanyMutation(); + const [updatePeople] = useUpdatePeopleMutation(); const containerRef = useRef(null); - useListenClickOutsideArrayOfRef([containerRef], () => { - onCreate(companyName, companyDomainName); - }); + function handleDoubleTextChange(leftValue: string, rightValue: string): void { + setCompanyDomainName(leftValue); + setCompanyName(rightValue); + } + + async function handleCompanyCreate( + companyName: string, + companyDomainName: string, + ) { + const newCompanyId = v4(); + + try { + await insertCompany({ + variables: { + id: newCompanyId, + name: companyName, + domainName: companyDomainName, + address: '', + createdAt: new Date().toISOString(), + }, + }); + + await updatePeople({ + variables: { + ...people, + companyId: newCompanyId, + }, + }); + } catch (error) { + // TODO: handle error better + logError(error); + } + + setIsCreating(false); + } useHotkeys( 'enter, escape', () => { - onCreate(companyName, companyDomainName); + handleCompanyCreate(companyName, companyDomainName); }, { enableOnFormTags: true, enableOnContentEditable: true, preventDefault: true, }, - [containerRef, companyName, companyDomainName, onCreate], + [companyName, companyDomainName, handleCompanyCreate], ); - function handleDoubleTextChange(leftValue: string, rightValue: string): void { - setCompanyDomainName(leftValue); - setCompanyName(rightValue); - } + useListenClickOutsideArrayOfRef([containerRef], () => { + handleCompanyCreate(companyName, companyDomainName); + }); return ( & { company?: Pick | null }; +}; + +export function PeopleCompanyPicker({ people }: OwnProps) { + const [, setIsCreating] = useRecoilScopedState(isCreateModeScopedState); + + const [searchFilter] = useRecoilScopedState( + relationPickerSearchFilterScopedState, + ); + const [updatePeople] = useUpdatePeopleMutation(); + const [, setIsSomeInputInEditMode] = useRecoilState( + isSomeInputInEditModeState, + ); + + const companies = useFilteredSearchEntityQuery({ + queryHook: useSearchCompanyQuery, + selectedIds: [people.company?.id ?? ''], + searchFilter: searchFilter, + mappingFunction: (company) => ({ + entityType: CommentableType.Company, + id: company.id, + name: company.name, + avatarType: 'squared', + avatarUrl: getLogoUrlFromDomainName(company.domainName), + }), + orderByField: 'name', + searchOnFields: ['name'], + }); + + async function handleEntitySelected(entity: any) { + setIsSomeInputInEditMode(false); + + await updatePeople({ + variables: { + ...people, + companyId: entity.id, + }, + }); + } + + function handleCreate() { + setIsCreating(true); + } + + return ( + + ); +} diff --git a/front/src/modules/relation-picker/components/SingleEntitySelect.tsx b/front/src/modules/relation-picker/components/SingleEntitySelect.tsx new file mode 100644 index 000000000..2b76f7a79 --- /dev/null +++ b/front/src/modules/relation-picker/components/SingleEntitySelect.tsx @@ -0,0 +1,105 @@ +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'; + +export type EntitiesForSingleEntitySelect< + CustomEntityForSelect extends EntityForSelect, +> = { + selectedEntity: CustomEntityForSelect; + entitiesToSelect: CustomEntityForSelect[]; +}; + +export function SingleEntitySelect< + CustomEntityForSelect extends EntityForSelect, +>({ + entities, + onEntitySelected, + onCreate, +}: { + onCreate?: () => void; + entities: EntitiesForSingleEntitySelect; + onEntitySelected: (entity: CustomEntityForSelect) => void; +}) { + const theme = useTheme(); + const containerRef = useRef(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 showCreateButton = isDefined(onCreate) && searchFilter !== ''; + + return ( + + + + {showCreateButton && ( + <> + + + + Create new + + + + + )} + + {entitiesInDropdown?.map((entity, index) => ( + onEntitySelected(entity)} + > + + {entity.name} + + ))} + {entitiesInDropdown?.length === 0 && ( + No result + )} + + + ); +} diff --git a/front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts b/front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts new file mode 100644 index 000000000..9cdf982f8 --- /dev/null +++ b/front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { debounce } from 'lodash'; +import scrollIntoView from 'scroll-into-view'; + +import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; + +import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState'; +import { EntityForSelect } from '../types/EntityForSelect'; + +export function useEntitySelectLogic< + CustomEntityForSelect extends EntityForSelect, +>({ + containerRef, + entities, +}: { + entities: CustomEntityForSelect[]; + containerRef: React.RefObject; +}) { + const [hoveredIndex, setHoveredIndex] = useState(0); + + const [searchFilter, setSearchFilter] = useRecoilScopedState( + relationPickerSearchFilterScopedState, + ); + + const debouncedSetSearchFilter = debounce(setSearchFilter, 100, { + leading: true, + }); + + function handleSearchFilterChange( + event: React.ChangeEvent, + ) { + debouncedSetSearchFilter(event.currentTarget.value); + setHoveredIndex(0); + } + + useHotkeys( + 'down', + () => { + setHoveredIndex((prevSelectedIndex) => + Math.min(prevSelectedIndex + 1, (entities?.length ?? 0) - 1), + ); + + const currentHoveredRef = containerRef.current?.children[ + hoveredIndex + ] as HTMLElement; + + if (currentHoveredRef) { + scrollIntoView(currentHoveredRef, { + align: { + top: 0.275, + }, + isScrollable: (target) => { + return target === containerRef.current; + }, + time: 0, + }); + } + }, + { + enableOnContentEditable: true, + enableOnFormTags: true, + preventDefault: true, + }, + [setHoveredIndex, entities], + ); + + useHotkeys( + 'up', + () => { + setHoveredIndex((prevSelectedIndex) => + Math.max(prevSelectedIndex - 1, 0), + ); + + const currentHoveredRef = containerRef.current?.children[ + hoveredIndex + ] as HTMLElement; + + if (currentHoveredRef) { + scrollIntoView(currentHoveredRef, { + align: { + top: 0.5, + }, + isScrollable: (target) => { + return target === containerRef.current; + }, + time: 0, + }); + } + }, + { + enableOnContentEditable: true, + enableOnFormTags: true, + preventDefault: true, + }, + [setHoveredIndex, entities], + ); + + return { + hoveredIndex, + searchFilter, + handleSearchFilterChange, + }; +} diff --git a/front/src/modules/relation-picker/states/relationPickerSearchFilterScopedState.ts b/front/src/modules/relation-picker/states/relationPickerSearchFilterScopedState.ts new file mode 100644 index 000000000..6be7ea8bf --- /dev/null +++ b/front/src/modules/relation-picker/states/relationPickerSearchFilterScopedState.ts @@ -0,0 +1,8 @@ +import { atomFamily } from 'recoil'; + +export const relationPickerSearchFilterScopedState = atomFamily( + { + key: 'relationPickerSearchFilterScopedState', + default: '', + }, +); diff --git a/front/src/modules/ui/components/editable-cell/EditableCellV2.tsx b/front/src/modules/ui/components/editable-cell/EditableCellV2.tsx new file mode 100644 index 000000000..5d440749e --- /dev/null +++ b/front/src/modules/ui/components/editable-cell/EditableCellV2.tsx @@ -0,0 +1,71 @@ +import { ReactElement } from 'react'; +import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; + +import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; + +import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState'; + +import { isEditModeScopedState } from './states/isEditModeScopedState'; +import { EditableCellDisplayMode } from './EditableCellDisplayMode'; +import { EditableCellEditMode } from './EditableCellEditMode'; + +export const CellBaseContainer = styled.div` + align-items: center; + box-sizing: border-box; + cursor: pointer; + display: flex; + height: 32px; + position: relative; + user-select: none; + width: 100%; +`; + +type OwnProps = { + editModeContent: ReactElement; + nonEditModeContent: ReactElement; + editModeHorizontalAlign?: 'left' | 'right'; + editModeVerticalPosition?: 'over' | 'below'; +}; + +export function EditableCellV2({ + editModeHorizontalAlign = 'left', + editModeVerticalPosition = 'over', + editModeContent, + nonEditModeContent, +}: OwnProps) { + const [isEditMode, setIsEditMode] = useRecoilScopedState( + isEditModeScopedState, + ); + const [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState( + isSomeInputInEditModeState, + ); + + function handleOnClick() { + if (!isSomeInputInEditMode) { + setIsSomeInputInEditMode(true); + setIsEditMode(true); + } + } + + function handleOnOutsideClick() { + setIsEditMode(false); + } + + return ( + + {isEditMode ? ( + + {editModeContent} + + ) : ( + {nonEditModeContent} + )} + + ); +} diff --git a/front/src/modules/ui/components/editable-cell/states/isCreateModeScopedState.ts b/front/src/modules/ui/components/editable-cell/states/isCreateModeScopedState.ts new file mode 100644 index 000000000..688d22ccf --- /dev/null +++ b/front/src/modules/ui/components/editable-cell/states/isCreateModeScopedState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const isCreateModeScopedState = atomFamily({ + key: 'isCreateModeScopedState', + default: false, +}); diff --git a/front/src/modules/ui/components/editable-cell/states/isEditModeScopedState.ts b/front/src/modules/ui/components/editable-cell/states/isEditModeScopedState.ts new file mode 100644 index 000000000..74b04feb4 --- /dev/null +++ b/front/src/modules/ui/components/editable-cell/states/isEditModeScopedState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const isEditModeScopedState = atomFamily({ + key: 'isEditModeScopedState', + default: false, +}); diff --git a/front/src/modules/ui/components/menu/DropdownMenuSelectableItem.tsx b/front/src/modules/ui/components/menu/DropdownMenuSelectableItem.tsx index c1ed649f1..7da6fa30f 100644 --- a/front/src/modules/ui/components/menu/DropdownMenuSelectableItem.tsx +++ b/front/src/modules/ui/components/menu/DropdownMenuSelectableItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -10,12 +10,17 @@ import { DropdownMenuButton } from './DropdownMenuButton'; type Props = { selected: boolean; onClick: () => void; + hovered?: boolean; }; const DropdownMenuSelectableItemContainer = styled(DropdownMenuButton)` ${hoverBackground}; align-items: center; + + background: ${(props) => + props.hovered ? props.theme.lightBackgroundTransparent : 'transparent'}; + display: flex; justify-content: space-between; `; @@ -35,10 +40,24 @@ export function DropdownMenuSelectableItem({ selected, onClick, children, + hovered, }: React.PropsWithChildren) { const theme = useTheme(); + + useEffect(() => { + if (hovered) { + window.scrollTo({ + behavior: 'smooth', + }); + } + }, [hovered]); + return ( - + {children} {selected && } diff --git a/front/src/modules/ui/components/select-entity/SelectSingleEntity.tsx b/front/src/modules/ui/components/select-entity/SelectSingleEntity.tsx deleted file mode 100644 index 6341dcc29..000000000 --- a/front/src/modules/ui/components/select-entity/SelectSingleEntity.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function SelectSingleEntity() { - return <>; -} diff --git a/front/src/modules/ui/hooks/RecoilScope.tsx b/front/src/modules/ui/hooks/RecoilScope.tsx new file mode 100644 index 000000000..5888f31dc --- /dev/null +++ b/front/src/modules/ui/hooks/RecoilScope.tsx @@ -0,0 +1,14 @@ +import { useState } from 'react'; +import { v4 } from 'uuid'; + +import { RecoilScopeContext } from './RecoilScopeContext'; + +export function RecoilScope({ children }: { children: React.ReactNode }) { + const [currentScopeId] = useState(v4()); + + return ( + + {children} + + ); +} diff --git a/front/src/modules/ui/hooks/RecoilScopeContext.ts b/front/src/modules/ui/hooks/RecoilScopeContext.ts new file mode 100644 index 000000000..b88dc68a2 --- /dev/null +++ b/front/src/modules/ui/hooks/RecoilScopeContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const RecoilScopeContext = createContext(null); diff --git a/front/src/modules/ui/hooks/useRecoilScopedState.ts b/front/src/modules/ui/hooks/useRecoilScopedState.ts new file mode 100644 index 000000000..708f9346e --- /dev/null +++ b/front/src/modules/ui/hooks/useRecoilScopedState.ts @@ -0,0 +1,17 @@ +import { useContext } from 'react'; +import { RecoilState, useRecoilState } from 'recoil'; + +import { RecoilScopeContext } from './RecoilScopeContext'; + +export function useRecoilScopedState( + recoilState: (param: string) => RecoilState, +) { + const recoilScopeId = useContext(RecoilScopeContext); + + if (!recoilScopeId) + throw new Error( + `Using a scoped atom without a RecoilScope : ${recoilState('').key}`, + ); + + return useRecoilState(recoilState(recoilScopeId)); +} diff --git a/front/src/modules/ui/hooks/useRecoilScopedValue.ts b/front/src/modules/ui/hooks/useRecoilScopedValue.ts new file mode 100644 index 000000000..b43005872 --- /dev/null +++ b/front/src/modules/ui/hooks/useRecoilScopedValue.ts @@ -0,0 +1,17 @@ +import { useContext } from 'react'; +import { RecoilState, useRecoilValue } from 'recoil'; + +import { RecoilScopeContext } from './RecoilScopeContext'; + +export function useRecoilScopedValue( + recoilState: (param: string) => RecoilState, +) { + const recoilScopeId = useContext(RecoilScopeContext); + + if (!recoilScopeId) + throw new Error( + `Using a scoped atom without a RecoilScope : ${recoilState('').key}`, + ); + + return useRecoilValue(recoilState(recoilScopeId)); +} diff --git a/front/src/pages/people/__stories__/People.inputs.stories.tsx b/front/src/pages/people/__stories__/People.inputs.stories.tsx index 21cef02dd..340315d22 100644 --- a/front/src/pages/people/__stories__/People.inputs.stories.tsx +++ b/front/src/pages/people/__stories__/People.inputs.stories.tsx @@ -1,10 +1,15 @@ +import { getOperationName } from '@apollo/client/utilities'; import { expect } from '@storybook/jest'; import type { Meta } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; import { graphql } from 'msw'; +import { UPDATE_PERSON } from '@/people/services'; +import { SEARCH_COMPANY_QUERY } from '@/search/services/search'; +import { Company } from '~/generated/graphql'; import { graphqlMocks } from '~/testing/graphqlMocks'; import { fetchOneFromData } from '~/testing/mock-data'; +import { mockedCompaniesData } from '~/testing/mock-data/companies'; import { mockedPeopleData } from '~/testing/mock-data/people'; import { getRenderWrapperForPage } from '~/testing/renderWrappers'; import { sleep } from '~/testing/sleep'; @@ -95,59 +100,121 @@ export const CheckCheckboxes: Story = { }, }; +const editRelationMocks = ( + initiallySelectedCompanyName: string, + searchCompanyNames: Array, + updateSelectedCompany: Pick, +) => [ + ...graphqlMocks.filter((graphqlMock) => { + if ( + typeof graphqlMock.info.operationName === 'string' && + [ + getOperationName(UPDATE_PERSON), + getOperationName(SEARCH_COMPANY_QUERY), + ].includes(graphqlMock.info.operationName) + ) { + return false; + } + return true; + }), + ...[ + graphql.mutation(getOperationName(UPDATE_PERSON) ?? '', (req, res, ctx) => { + return res( + ctx.data({ + updateOnePerson: { + ...fetchOneFromData(mockedPeopleData, req.variables.id), + ...{ + company: { + id: req.variables.companyId, + name: updateSelectedCompany.name, + domainName: updateSelectedCompany.domainName, + __typename: 'Company', + }, + }, + }, + }), + ); + }), + graphql.query( + getOperationName(SEARCH_COMPANY_QUERY) ?? '', + (req, res, ctx) => { + if (!req.variables.where?.AND) { + // Selected company case + const searchResults = mockedCompaniesData.filter((company) => + [initiallySelectedCompanyName].includes(company.name), + ); + return res( + ctx.data({ + searchResults: searchResults, + }), + ); + } + + if ( + req.variables.where?.AND?.some( + (where: { id?: { in: Array } }) => where.id?.in, + ) + ) { + // Selected company case + const searchResults = mockedCompaniesData.filter((company) => + [initiallySelectedCompanyName].includes(company.name), + ); + return res( + ctx.data({ + searchResults: searchResults, + }), + ); + } else { + // Search case + + const searchResults = mockedCompaniesData.filter((company) => + searchCompanyNames.includes(company.name), + ); + return res( + ctx.data({ + searchResults: searchResults, + }), + ); + } + }, + ), + ], +]; + export const EditRelation: Story = { render: getRenderWrapperForPage(, '/people'), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const secondRowCompanyCell = await canvas.findByText( + const firstRowCompanyCell = await canvas.findByText( mockedPeopleData[1].company.name, ); - await userEvent.click(secondRowCompanyCell); + await userEvent.click(firstRowCompanyCell); - const relationInput = await canvas.findByPlaceholderText('Company'); + const relationInput = await canvas.findByPlaceholderText('Search'); await userEvent.type(relationInput, 'Air', { delay: 200, }); const airbnbChip = await canvas.findByText('Airbnb', { - selector: 'div > span', + selector: 'div', }); await userEvent.click(airbnbChip); - const newSecondRowCompanyCell = await canvas.findByText('Airbnb'); + const otherCell = await canvas.findByText('Janice Dane'); + await userEvent.click(otherCell); - await userEvent.click(newSecondRowCompanyCell); + await canvas.findByText('Airbnb'); }, parameters: { actions: {}, - msw: [ - ...graphqlMocks.filter((graphqlMock) => { - return graphqlMock.info.operationName !== 'UpdatePeople'; - }), - ...[ - graphql.mutation('UpdatePeople', (req, res, ctx) => { - return res( - ctx.data({ - updateOnePerson: { - ...fetchOneFromData(mockedPeopleData, req.variables.id), - ...{ - company: { - id: req.variables.companyId, - name: 'Airbnb', - domainName: 'airbnb.com', - __typename: 'Company', - }, - }, - }, - }), - ); - }), - ], - ], + msw: editRelationMocks('Qonto', ['Airbnb', 'Aircall'], { + name: 'Airbnb', + domainName: 'airbnb.com', + }), }, }; @@ -157,51 +224,32 @@ export const SelectRelationWithKeys: Story = { const canvas = within(canvasElement); const thirdRowCompanyCell = await canvas.findByText( - mockedPeopleData[2].company.name, + mockedPeopleData[0].company.name, ); await userEvent.click(thirdRowCompanyCell); - const relationInput = await canvas.findByPlaceholderText('Company'); + const relationInput = await canvas.findByPlaceholderText('Search'); await userEvent.type(relationInput, 'Air', { delay: 200, }); - await userEvent.type(relationInput, '{arrowdown}'); await userEvent.type(relationInput, '{arrowdown}'); await userEvent.type(relationInput, '{arrowup}'); await userEvent.type(relationInput, '{arrowdown}'); + await userEvent.type(relationInput, '{arrowdown}'); await userEvent.type(relationInput, '{enter}'); + sleep(25); - const newThirdRowCompanyCell = await canvas.findByText('Aircall'); - await userEvent.click(newThirdRowCompanyCell); + const allAirbns = await canvas.findAllByText('Aircall'); + expect(allAirbns.length).toBe(1); }, parameters: { actions: {}, - msw: [ - ...graphqlMocks.filter((graphqlMock) => { - return graphqlMock.info.operationName !== 'UpdatePeople'; - }), - ...[ - graphql.mutation('UpdatePeople', (req, res, ctx) => { - return res( - ctx.data({ - updateOnePerson: { - ...fetchOneFromData(mockedPeopleData, req.variables.id), - ...{ - company: { - id: req.variables.companyId, - name: 'Aircall', - domainName: 'aircall.io', - __typename: 'Company', - }, - }, - }, - }), - ); - }), - ], - ], + msw: editRelationMocks('Qonto', ['Airbnb', 'Aircall'], { + name: 'Aircall', + domainName: 'aircall.io', + }), }, }; diff --git a/front/src/pages/people/people-columns.tsx b/front/src/pages/people/people-columns.tsx index 718dfc950..6db7e80f7 100644 --- a/front/src/pages/people/people-columns.tsx +++ b/front/src/pages/people/people-columns.tsx @@ -7,6 +7,7 @@ import { EditableDate } from '@/ui/components/editable-cell/types/EditableDate'; import { EditablePhone } from '@/ui/components/editable-cell/types/EditablePhone'; import { EditableText } from '@/ui/components/editable-cell/types/EditableText'; import { ColumnHead } from '@/ui/components/table/ColumnHead'; +import { RecoilScope } from '@/ui/hooks/RecoilScope'; import { IconBuildingSkyscraper, IconCalendarEvent, @@ -79,7 +80,11 @@ export const usePeopleColumns = () => { viewIcon={} /> ), - cell: (props) => , + cell: (props) => ( + + + + ), size: 150, }), columnHelper.accessor('phone', { diff --git a/front/src/testing/graphqlMocks.ts b/front/src/testing/graphqlMocks.ts index 2405ec860..e31ad2ace 100644 --- a/front/src/testing/graphqlMocks.ts +++ b/front/src/testing/graphqlMocks.ts @@ -45,7 +45,9 @@ export const graphqlMocks = [ >( mockedCompaniesData, req.variables.where, - req.variables.orderBy, + Array.isArray(req.variables.orderBy) + ? req.variables.orderBy + : [req.variables.orderBy], req.variables.limit, ); return res( diff --git a/front/src/testing/mock-data/index.ts b/front/src/testing/mock-data/index.ts index 517fa1cdf..aaef8ad74 100644 --- a/front/src/testing/mock-data/index.ts +++ b/front/src/testing/mock-data/index.ts @@ -86,7 +86,7 @@ export function filterAndSortData( filteredData = filterData(data, where); } - if (orderBy) { + if (orderBy && Array.isArray(orderBy) && orderBy.length > 0 && orderBy[0]) { const firstOrderBy = orderBy[0]; const key = Object.keys(firstOrderBy)[0]; diff --git a/front/src/testing/mock-data/people.ts b/front/src/testing/mock-data/people.ts index 360abab93..d60be76e7 100644 --- a/front/src/testing/mock-data/people.ts +++ b/front/src/testing/mock-data/people.ts @@ -23,7 +23,7 @@ export const mockedPeopleData: Array = [ lastname: 'Prot', email: 'alexandre@qonto.com', company: { - id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6c', + id: '5c21e19e-e049-4393-8c09-3e3f8fb09ecb', name: 'Qonto', domainName: 'qonto.com', __typename: 'Company', diff --git a/front/yarn.lock b/front/yarn.lock index 68227dda3..d7b2ed1d5 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -5070,6 +5070,11 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== +"@types/scroll-into-view@^1.16.0": + version "1.16.0" + resolved "https://registry.yarnpkg.com/@types/scroll-into-view/-/scroll-into-view-1.16.0.tgz#bd02094307624cc0243c34dca478510a0a8515e8" + integrity sha512-WT0YBP7CLi3XH/gDbWdtBf4mQVyE7zQrpEZ2rNrxz9tVoIPJf97zGlfRqnkECj7P8rPkFxVlo1KbOOMetcchdA== + "@types/semver@^7.3.12", "@types/semver@^7.3.4": version "7.5.0" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.0.tgz#591c1ce3a702c45ee15f47a42ade72c2fd78978a" @@ -15137,6 +15142,11 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +scroll-into-view@^1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/scroll-into-view/-/scroll-into-view-1.16.2.tgz#ea3e810dacc861fb9c115eac7bf603e564f0104a" + integrity sha512-vyTE0i27o6eldt9xinjHec41Dw05y+faoI+s2zNKJAVOdbA5M2XZrYq/obJ8E+QDQulJ2gDjgui9w9m9RZSRng== + scuid@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/scuid/-/scuid-1.1.0.tgz#d3f9f920956e737a60f72d0e4ad280bf324d5dab"