Feat/single entity select relation picker (#345)

* - Implemented recoil scoped state
- Implemented SingleEntitySelect
- Implemented keyboard shortcut up/down select

* Added useRecoilScopedValue

* Fix storybook

* Fix storybook

* Fix storybook

* Fix storybook

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau 2023-06-21 22:29:07 +02:00 committed by GitHub
parent 8a330b9746
commit e679f45615
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 653 additions and 180 deletions

View File

@ -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",

View File

@ -75,7 +75,6 @@ export const Board = ({
[board, onUpdate],
);
console.log('board', board);
return (
<StyledBoard>
<DragDropContext onDragEnd={onDragEnd}>

View File

@ -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<Person, 'id'> & {
company?: Pick<Company, 'id' | 'name'> | null;
company?: Pick<Company, 'id' | 'name' | 'domainName'> | 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 ? (
<PeopleCompanyCreateCell
initialCompanyName={initialCompanyName}
onCreate={handleCompanyCreate}
/>
<PeopleCompanyCreateCell people={people} />
) : (
<EditableRelation<any, CompanyChipPropsType>
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
<EditableCellV2
editModeContent={<PeopleCompanyPicker people={people} />}
nonEditModeContent={
<CompanyChip
name={people.company?.name ?? ''}
picture={getLogoUrlFromDomainName(people.company?.domainName)}
/>
}
onCreate={() => {
setIsCreating(true);
}}
/>
);
}

View File

@ -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<Person, 'id'>;
};
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 (
<DoubleTextInput

View File

@ -0,0 +1,73 @@
import { useRecoilState } from 'recoil';
import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect';
import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery';
import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState';
import { isCreateModeScopedState } from '@/ui/components/editable-cell/states/isCreateModeScopedState';
import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState';
import { isSomeInputInEditModeState } from '@/ui/tables/states/isSomeInputInEditModeState';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
CommentableType,
Company,
Person,
useSearchCompanyQuery,
useUpdatePeopleMutation,
} from '~/generated/graphql';
export type OwnProps = {
people: Pick<Person, 'id'> & { company?: Pick<Company, 'id'> | 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 (
<SingleEntitySelect
onCreate={handleCreate}
onEntitySelected={handleEntitySelected}
entities={{
entitiesToSelect: companies.entitiesToSelect,
selectedEntity: companies.selectedEntities[0],
}}
/>
);
}

View File

@ -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<CustomEntityForSelect>;
onEntitySelected: (entity: CustomEntityForSelect) => void;
}) {
const theme = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const entitiesInDropdown = isDefined(entities.selectedEntity)
? [entities.selectedEntity, ...(entities.entitiesToSelect ?? [])]
: entities.entitiesToSelect ?? [];
const { hoveredIndex, searchFilter, handleSearchFilterChange } =
useEntitySelectLogic({
entities: entitiesInDropdown,
containerRef,
});
useHotkeys(
'enter',
() => {
onEntitySelected(entitiesInDropdown[hoveredIndex]);
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[entitiesInDropdown, hoveredIndex, onEntitySelected],
);
const showCreateButton = isDefined(onCreate) && searchFilter !== '';
return (
<DropdownMenu>
<DropdownMenuSearch
value={searchFilter}
onChange={handleSearchFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
{showCreateButton && (
<>
<DropdownMenuItemContainer>
<DropdownMenuButton onClick={onCreate}>
<IconPlus size={theme.iconSizeMedium} />
Create new
</DropdownMenuButton>
</DropdownMenuItemContainer>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItemContainer ref={containerRef}>
{entitiesInDropdown?.map((entity, index) => (
<DropdownMenuSelectableItem
key={entity.id}
selected={entities.selectedEntity?.id === entity.id}
hovered={hoveredIndex === index}
onClick={() => onEntitySelected(entity)}
>
<Avatar
avatarUrl={entity.avatarUrl}
placeholder={entity.name}
size={16}
type={entity.avatarType ?? 'rounded'}
/>
{entity.name}
</DropdownMenuSelectableItem>
))}
{entitiesInDropdown?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemContainer>
</DropdownMenu>
);
}

View File

@ -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<HTMLDivElement>;
}) {
const [hoveredIndex, setHoveredIndex] = useState(0);
const [searchFilter, setSearchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
function handleSearchFilterChange(
event: React.ChangeEvent<HTMLInputElement>,
) {
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,
};
}

View File

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

View File

@ -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 (
<CellBaseContainer onClick={handleOnClick}>
{isEditMode ? (
<EditableCellEditMode
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
isEditMode={isEditMode}
onOutsideClick={handleOnOutsideClick}
>
{editModeContent}
</EditableCellEditMode>
) : (
<EditableCellDisplayMode>{nonEditModeContent}</EditableCellDisplayMode>
)}
</CellBaseContainer>
);
}

View File

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

View File

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

View File

@ -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)<Props>`
${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<Props>) {
const theme = useTheme();
useEffect(() => {
if (hovered) {
window.scrollTo({
behavior: 'smooth',
});
}
}, [hovered]);
return (
<DropdownMenuSelectableItemContainer onClick={onClick} selected={selected}>
<DropdownMenuSelectableItemContainer
onClick={onClick}
selected={selected}
hovered={hovered}
>
<StyledLeftContainer>{children}</StyledLeftContainer>
<StyledRightIcon>
{selected && <IconCheck size={theme.iconSizeMedium} />}

View File

@ -1,3 +0,0 @@
export function SelectSingleEntity() {
return <></>;
}

View File

@ -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 (
<RecoilScopeContext.Provider value={currentScopeId}>
{children}
</RecoilScopeContext.Provider>
);
}

View File

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

View File

@ -0,0 +1,17 @@
import { useContext } from 'react';
import { RecoilState, useRecoilState } from 'recoil';
import { RecoilScopeContext } from './RecoilScopeContext';
export function useRecoilScopedState<T>(
recoilState: (param: string) => RecoilState<T>,
) {
const recoilScopeId = useContext(RecoilScopeContext);
if (!recoilScopeId)
throw new Error(
`Using a scoped atom without a RecoilScope : ${recoilState('').key}`,
);
return useRecoilState<T>(recoilState(recoilScopeId));
}

View File

@ -0,0 +1,17 @@
import { useContext } from 'react';
import { RecoilState, useRecoilValue } from 'recoil';
import { RecoilScopeContext } from './RecoilScopeContext';
export function useRecoilScopedValue<T>(
recoilState: (param: string) => RecoilState<T>,
) {
const recoilScopeId = useContext(RecoilScopeContext);
if (!recoilScopeId)
throw new Error(
`Using a scoped atom without a RecoilScope : ${recoilState('').key}`,
);
return useRecoilValue<T>(recoilState(recoilScopeId));
}

View File

@ -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<string>,
updateSelectedCompany: Pick<Company, 'name' | 'domainName'>,
) => [
...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<string> } }) => 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 />, '/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',
}),
},
};

View File

@ -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={<IconBuildingSkyscraper size={16} />}
/>
),
cell: (props) => <PeopleCompanyCell people={props.row.original} />,
cell: (props) => (
<RecoilScope>
<PeopleCompanyCell people={props.row.original} />
</RecoilScope>
),
size: 150,
}),
columnHelper.accessor('phone', {

View File

@ -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(

View File

@ -86,7 +86,7 @@ export function filterAndSortData<DataT>(
filteredData = filterData<DataT>(data, where);
}
if (orderBy) {
if (orderBy && Array.isArray(orderBy) && orderBy.length > 0 && orderBy[0]) {
const firstOrderBy = orderBy[0];
const key = Object.keys(firstOrderBy)[0];

View File

@ -23,7 +23,7 @@ export const mockedPeopleData: Array<MockedPerson> = [
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',

View File

@ -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"