mirror of
https://github.com/twentyhq/twenty.git
synced 2024-10-05 05:17:40 +03:00
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:
parent
8a330b9746
commit
e679f45615
@ -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",
|
||||
|
@ -75,7 +75,6 @@ export const Board = ({
|
||||
[board, onUpdate],
|
||||
);
|
||||
|
||||
console.log('board', board);
|
||||
return (
|
||||
<StyledBoard>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
73
front/src/modules/people/components/PeopleCompanyPicker.tsx
Normal file
73
front/src/modules/people/components/PeopleCompanyPicker.tsx
Normal 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],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
104
front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts
Normal file
104
front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const relationPickerSearchFilterScopedState = atomFamily<string, string>(
|
||||
{
|
||||
key: 'relationPickerSearchFilterScopedState',
|
||||
default: '',
|
||||
},
|
||||
);
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const isCreateModeScopedState = atomFamily<boolean, string>({
|
||||
key: 'isCreateModeScopedState',
|
||||
default: false,
|
||||
});
|
@ -0,0 +1,6 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const isEditModeScopedState = atomFamily<boolean, string>({
|
||||
key: 'isEditModeScopedState',
|
||||
default: false,
|
||||
});
|
@ -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} />}
|
||||
|
@ -1,3 +0,0 @@
|
||||
export function SelectSingleEntity() {
|
||||
return <></>;
|
||||
}
|
14
front/src/modules/ui/hooks/RecoilScope.tsx
Normal file
14
front/src/modules/ui/hooks/RecoilScope.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
front/src/modules/ui/hooks/RecoilScopeContext.ts
Normal file
3
front/src/modules/ui/hooks/RecoilScopeContext.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const RecoilScopeContext = createContext<string | null>(null);
|
17
front/src/modules/ui/hooks/useRecoilScopedState.ts
Normal file
17
front/src/modules/ui/hooks/useRecoilScopedState.ts
Normal 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));
|
||||
}
|
17
front/src/modules/ui/hooks/useRecoilScopedValue.ts
Normal file
17
front/src/modules/ui/hooks/useRecoilScopedValue.ts
Normal 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));
|
||||
}
|
@ -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',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
@ -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', {
|
||||
|
@ -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(
|
||||
|
@ -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];
|
||||
|
@ -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',
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user