Infinite scrolling in relation picker menu (#12051)
https://github.com/user-attachments/assets/4be785e0-ea8a-4c8e-840e-6fa0a663d7ba Closes #11938 --------- Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
@ -172,7 +172,13 @@ export const NoResultsSearchFallback: Story = {
|
||||
graphql.query('Search', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
search: [],
|
||||
search: {
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
endCursor: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const search = gql`
|
||||
export const SEARCH_QUERY = gql`
|
||||
query Search(
|
||||
$searchInput: String!
|
||||
$limit: Int!
|
||||
$after: String
|
||||
$excludedObjectNameSingulars: [String!]
|
||||
$includedObjectNameSingulars: [String!]
|
||||
$filter: ObjectRecordFilterInput
|
||||
@ -11,16 +12,26 @@ export const search = gql`
|
||||
search(
|
||||
searchInput: $searchInput
|
||||
limit: $limit
|
||||
after: $after
|
||||
excludedObjectNameSingulars: $excludedObjectNameSingulars
|
||||
includedObjectNameSingulars: $includedObjectNameSingulars
|
||||
filter: $filter
|
||||
) {
|
||||
recordId
|
||||
objectNameSingular
|
||||
label
|
||||
imageUrl
|
||||
tsRankCD
|
||||
tsRank
|
||||
edges {
|
||||
node {
|
||||
recordId
|
||||
objectNameSingular
|
||||
label
|
||||
imageUrl
|
||||
tsRankCD
|
||||
tsRank
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -31,69 +31,71 @@ export const useCommandMenuSearchRecords = () => {
|
||||
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
return (searchData?.search ?? []).map((searchRecord, index) => {
|
||||
const baseAction = {
|
||||
type: ActionType.Navigation,
|
||||
scope: ActionScope.Global,
|
||||
key: searchRecord.recordId,
|
||||
label: searchRecord.label,
|
||||
position: index,
|
||||
Icon: () => (
|
||||
<Avatar
|
||||
type={
|
||||
searchRecord.objectNameSingular === 'company'
|
||||
? 'squared'
|
||||
: 'rounded'
|
||||
}
|
||||
avatarUrl={searchRecord.imageUrl}
|
||||
placeholderColorSeed={searchRecord.recordId}
|
||||
placeholder={searchRecord.label}
|
||||
/>
|
||||
),
|
||||
shouldBeRegistered: () => true,
|
||||
description: capitalize(searchRecord.objectNameSingular),
|
||||
shouldCloseCommandMenuOnClick: true,
|
||||
};
|
||||
return (searchData?.search.edges.map((edge) => edge.node) ?? []).map(
|
||||
(searchRecord, index) => {
|
||||
const baseAction = {
|
||||
type: ActionType.Navigation,
|
||||
scope: ActionScope.Global,
|
||||
key: searchRecord.recordId,
|
||||
label: searchRecord.label,
|
||||
position: index,
|
||||
Icon: () => (
|
||||
<Avatar
|
||||
type={
|
||||
searchRecord.objectNameSingular === 'company'
|
||||
? 'squared'
|
||||
: 'rounded'
|
||||
}
|
||||
avatarUrl={searchRecord.imageUrl}
|
||||
placeholderColorSeed={searchRecord.recordId}
|
||||
placeholder={searchRecord.label}
|
||||
/>
|
||||
),
|
||||
shouldBeRegistered: () => true,
|
||||
description: capitalize(searchRecord.objectNameSingular),
|
||||
shouldCloseCommandMenuOnClick: true,
|
||||
};
|
||||
|
||||
if (
|
||||
[CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes(
|
||||
searchRecord.objectNameSingular as CoreObjectNameSingular,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
...baseAction,
|
||||
component: (
|
||||
<Action
|
||||
onClick={() => {
|
||||
searchRecord.objectNameSingular === 'task'
|
||||
? openRecordInCommandMenu({
|
||||
recordId: searchRecord.recordId,
|
||||
objectNameSingular: CoreObjectNameSingular.Task,
|
||||
})
|
||||
: openRecordInCommandMenu({
|
||||
recordId: searchRecord.recordId,
|
||||
objectNameSingular: CoreObjectNameSingular.Note,
|
||||
});
|
||||
}}
|
||||
preventCommandMenuClosing
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
[CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes(
|
||||
searchRecord.objectNameSingular as CoreObjectNameSingular,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
...baseAction,
|
||||
component: (
|
||||
<Action
|
||||
onClick={() => {
|
||||
searchRecord.objectNameSingular === 'task'
|
||||
? openRecordInCommandMenu({
|
||||
recordId: searchRecord.recordId,
|
||||
objectNameSingular: CoreObjectNameSingular.Task,
|
||||
})
|
||||
: openRecordInCommandMenu({
|
||||
recordId: searchRecord.recordId,
|
||||
objectNameSingular: CoreObjectNameSingular.Note,
|
||||
});
|
||||
<ActionLink
|
||||
to={AppPath.RecordShowPage}
|
||||
params={{
|
||||
objectNameSingular: searchRecord.objectNameSingular,
|
||||
objectRecordId: searchRecord.recordId,
|
||||
}}
|
||||
preventCommandMenuClosing
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseAction,
|
||||
component: (
|
||||
<ActionLink
|
||||
to={AppPath.RecordShowPage}
|
||||
params={{
|
||||
objectNameSingular: searchRecord.objectNameSingular,
|
||||
objectRecordId: searchRecord.recordId,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
}, [searchData, openRecordInCommandMenu]);
|
||||
|
||||
return {
|
||||
|
||||
@ -65,7 +65,7 @@ export const useObjectRecordSearchRecords = ({
|
||||
const effectiveData = loading ? previousData : data;
|
||||
|
||||
const searchRecords = useMemo(
|
||||
() => effectiveData?.search || [],
|
||||
() => effectiveData?.search.edges.map((edge) => edge.node) || [],
|
||||
[effectiveData],
|
||||
);
|
||||
|
||||
|
||||
@ -1,26 +1,21 @@
|
||||
import { MultipleRecordPickerMenuItems } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems';
|
||||
import { MultipleRecordPickerItemsDisplay } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerItemsDisplay';
|
||||
import { MultipleRecordPickerOnClickOutsideEffect } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerOnClickOutsideEffect';
|
||||
import { MultipleRecordPickerSearchInput } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput';
|
||||
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
|
||||
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
|
||||
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
|
||||
import { multipleRecordPickerPickableMorphItemsLengthComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableMorphItemsLengthComponentSelector';
|
||||
import { MultipleRecordPickerHotkeyScope } from '@/object-record/record-picker/multiple-record-picker/types/MultipleRecordPickerHotkeyScope';
|
||||
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
|
||||
import { RecordPickerLayoutDirection } from '@/object-record/record-picker/types/RecordPickerLayoutDirection';
|
||||
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
|
||||
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
|
||||
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
|
||||
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRef } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
@ -59,16 +54,6 @@ export const MultipleRecordPicker = ({
|
||||
selectableListComponentInstanceId,
|
||||
);
|
||||
|
||||
const multipleRecordPickerIsLoading = useRecoilComponentValueV2(
|
||||
multipleRecordPickerIsLoadingComponentState,
|
||||
componentInstanceId,
|
||||
);
|
||||
|
||||
const itemsLength = useRecoilComponentValueV2(
|
||||
multipleRecordPickerPickableMorphItemsLengthComponentSelector,
|
||||
componentInstanceId,
|
||||
);
|
||||
|
||||
const multipleRecordPickerSearchFilterState =
|
||||
useRecoilComponentCallbackStateV2(
|
||||
multipleRecordPickerSearchFilterComponentState,
|
||||
@ -106,13 +91,16 @@ export const MultipleRecordPicker = ({
|
||||
[multipleRecordPickerSearchFilterState, onCreate],
|
||||
);
|
||||
|
||||
const createNewButton = isDefined(onCreate) && (
|
||||
<CreateNewButton
|
||||
onClick={handleCreateNewButtonClick}
|
||||
LeftIcon={IconPlus}
|
||||
text="Add New"
|
||||
/>
|
||||
);
|
||||
const createNewButtonSection =
|
||||
isDefined(onCreate) && !hasObjectReadOnlyPermission ? (
|
||||
<DropdownMenuItemsContainer scrollable={false}>
|
||||
<CreateNewButton
|
||||
onClick={handleCreateNewButtonClick}
|
||||
LeftIcon={IconPlus}
|
||||
text="Add New"
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MultipleRecordPickerComponentInstanceContext.Provider
|
||||
@ -125,43 +113,15 @@ export const MultipleRecordPicker = ({
|
||||
<DropdownMenu ref={containerRef} data-select-disable width={200}>
|
||||
{layoutDirection === 'search-bar-on-bottom' && (
|
||||
<>
|
||||
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
|
||||
<DropdownMenuItemsContainer scrollable={false}>
|
||||
{createNewButton}
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{itemsLength > 0 && (
|
||||
<MultipleRecordPickerMenuItems onChange={onChange} />
|
||||
)}
|
||||
{multipleRecordPickerIsLoading && (
|
||||
<>
|
||||
<DropdownMenuSkeletonItem />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{itemsLength > 0 && <DropdownMenuSeparator />}
|
||||
{createNewButtonSection}
|
||||
<MultipleRecordPickerItemsDisplay onChange={onChange} />
|
||||
</>
|
||||
)}
|
||||
<MultipleRecordPickerSearchInput />
|
||||
{layoutDirection === 'search-bar-on-top' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{multipleRecordPickerIsLoading && (
|
||||
<>
|
||||
<DropdownMenuSkeletonItem />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{itemsLength > 0 && (
|
||||
<MultipleRecordPickerMenuItems onChange={onChange} />
|
||||
)}
|
||||
{itemsLength > 0 && <DropdownMenuSeparator />}
|
||||
{isDefined(onCreate) && (
|
||||
<DropdownMenuItemsContainer scrollable={false}>
|
||||
{createNewButton}
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
<MultipleRecordPickerItemsDisplay onChange={onChange} />
|
||||
{createNewButtonSection}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
|
||||
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
|
||||
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
|
||||
import { multipleRecordPickerPaginationState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState';
|
||||
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
|
||||
import { multipleRecordPickerPaginationSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPaginationSelector';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { GRAY_SCALE } from 'twenty-ui/theme';
|
||||
|
||||
const StyledText = styled.div`
|
||||
align-items: center;
|
||||
box-shadow: none;
|
||||
color: ${GRAY_SCALE.gray40};
|
||||
display: flex;
|
||||
height: 32px;
|
||||
margin-left: ${({ theme }) => theme.spacing(8)};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledIntersectionObserver = styled.div`
|
||||
height: 1px;
|
||||
`;
|
||||
|
||||
export const MultipleRecordPickerFetchMoreLoader = () => {
|
||||
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
|
||||
MultipleRecordPickerComponentInstanceContext,
|
||||
);
|
||||
|
||||
const paginationState = useRecoilComponentValueV2(
|
||||
multipleRecordPickerPaginationSelector,
|
||||
componentInstanceId,
|
||||
);
|
||||
|
||||
const isLoading = useRecoilComponentValueV2(
|
||||
multipleRecordPickerIsLoadingComponentState,
|
||||
componentInstanceId,
|
||||
);
|
||||
|
||||
const searchFilter = useRecoilComponentValueV2(
|
||||
multipleRecordPickerSearchFilterComponentState,
|
||||
componentInstanceId,
|
||||
);
|
||||
|
||||
const { performSearch } = useMultipleRecordPickerPerformSearch();
|
||||
|
||||
const fetchMore = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
async () => {
|
||||
const paginationState = snapshot
|
||||
.getLoadable(
|
||||
multipleRecordPickerPaginationState.atomFamily({
|
||||
instanceId: componentInstanceId,
|
||||
}),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
if (isLoading || !paginationState.hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
performSearch({
|
||||
multipleRecordPickerInstanceId: componentInstanceId,
|
||||
forceSearchFilter: searchFilter,
|
||||
loadMore: true,
|
||||
});
|
||||
},
|
||||
[componentInstanceId, performSearch, searchFilter, isLoading],
|
||||
);
|
||||
|
||||
const { ref } = useInView({
|
||||
onChange: useCallback(
|
||||
(inView: boolean) => {
|
||||
if (inView) {
|
||||
fetchMore();
|
||||
}
|
||||
},
|
||||
[fetchMore],
|
||||
),
|
||||
});
|
||||
|
||||
if (!paginationState.hasNextPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StyledIntersectionObserver ref={ref} />
|
||||
{isLoading && <StyledText>Loading more...</StyledText>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
import { MultipleRecordPickerMenuItems } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems';
|
||||
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
|
||||
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
|
||||
import { multipleRecordPickerPickableMorphItemsLengthComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableMorphItemsLengthComponentSelector';
|
||||
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
|
||||
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
|
||||
export const MultipleRecordPickerItemsDisplay = ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
|
||||
}) => {
|
||||
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
|
||||
MultipleRecordPickerComponentInstanceContext,
|
||||
);
|
||||
|
||||
const isLoading = useRecoilComponentValueV2(
|
||||
multipleRecordPickerIsLoadingComponentState,
|
||||
componentInstanceId,
|
||||
);
|
||||
|
||||
const itemsLength = useRecoilComponentValueV2(
|
||||
multipleRecordPickerPickableMorphItemsLengthComponentSelector,
|
||||
componentInstanceId,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{isLoading && itemsLength === 0 ? (
|
||||
<DropdownMenuSkeletonItem />
|
||||
) : (
|
||||
<MultipleRecordPickerMenuItems onChange={onChange} />
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { MultipleRecordPickerFetchMoreLoader } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerFetchMoreLoader';
|
||||
import { MultipleRecordPickerMenuItem } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItem';
|
||||
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
|
||||
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
|
||||
@ -21,6 +22,14 @@ export const StyledSelectableItem = styled(SelectableListItem)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEmptyText = styled.div`
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
type MultipleRecordPickerMenuItemsProps = {
|
||||
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
|
||||
};
|
||||
@ -77,25 +86,30 @@ export const MultipleRecordPickerMenuItems = ({
|
||||
|
||||
return (
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
<SelectableList
|
||||
selectableListInstanceId={selectableListComponentInstanceId}
|
||||
selectableItemIdArray={pickableRecordIds}
|
||||
hotkeyScope={MultipleRecordPickerHotkeyScope.MultipleRecordPicker}
|
||||
>
|
||||
{pickableRecordIds.map((recordId) => {
|
||||
return (
|
||||
<MultipleRecordPickerMenuItem
|
||||
key={recordId}
|
||||
recordId={recordId}
|
||||
onChange={(morphItem) => {
|
||||
handleChange(morphItem);
|
||||
onChange?.(morphItem);
|
||||
resetSelectedItem();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SelectableList>
|
||||
{pickableRecordIds.length === 0 ? (
|
||||
<StyledEmptyText>No results found</StyledEmptyText>
|
||||
) : (
|
||||
<SelectableList
|
||||
selectableListInstanceId={selectableListComponentInstanceId}
|
||||
selectableItemIdArray={pickableRecordIds}
|
||||
hotkeyScope={MultipleRecordPickerHotkeyScope.MultipleRecordPicker}
|
||||
>
|
||||
{pickableRecordIds.map((recordId) => {
|
||||
return (
|
||||
<MultipleRecordPickerMenuItem
|
||||
key={recordId}
|
||||
recordId={recordId}
|
||||
onChange={(morphItem) => {
|
||||
handleChange(morphItem);
|
||||
onChange?.(morphItem);
|
||||
resetSelectedItem();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<MultipleRecordPickerFetchMoreLoader />
|
||||
</SelectableList>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,7 +4,8 @@ import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
|
||||
import { useCallback } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
export const MultipleRecordPickerSearchInput = () => {
|
||||
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
|
||||
@ -16,17 +17,33 @@ export const MultipleRecordPickerSearchInput = () => {
|
||||
|
||||
const { performSearch } = useMultipleRecordPickerPerformSearch();
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRecordPickerSearchFilter(event.currentTarget.value);
|
||||
performSearch({
|
||||
multipleRecordPickerInstanceId: componentInstanceId,
|
||||
forceSearchFilter: event.currentTarget.value,
|
||||
});
|
||||
},
|
||||
[componentInstanceId, performSearch, setRecordPickerSearchFilter],
|
||||
const debouncedSearch = useDebouncedCallback(
|
||||
useRecoilCallback(
|
||||
({ set }) =>
|
||||
(searchFilter: string) => {
|
||||
set(
|
||||
multipleRecordPickerSearchFilterComponentState.atomFamily({
|
||||
instanceId: componentInstanceId,
|
||||
}),
|
||||
searchFilter,
|
||||
);
|
||||
|
||||
performSearch({
|
||||
multipleRecordPickerInstanceId: componentInstanceId,
|
||||
forceSearchFilter: searchFilter,
|
||||
});
|
||||
},
|
||||
[componentInstanceId, performSearch],
|
||||
),
|
||||
500,
|
||||
);
|
||||
|
||||
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newSearchFilter = event.currentTarget.value;
|
||||
setRecordPickerSearchFilter(newSearchFilter);
|
||||
debouncedSearch(newSearchFilter);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuSearchInput
|
||||
value={recordPickerSearchFilter}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
|
||||
import { search } from '@/command-menu/graphql/queries/search';
|
||||
import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { usePerformCombinedFindManyRecords } from '@/object-record/multiple-objects/hooks/usePerformCombinedFindManyRecords';
|
||||
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
|
||||
import { multipleRecordPickerPaginationState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState';
|
||||
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
|
||||
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
|
||||
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
|
||||
@ -12,6 +13,9 @@ import { isNonEmptyArray } from '@sniptt/guards';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { SearchRecord } from '~/generated-metadata/graphql';
|
||||
import { SearchResultEdge } from '~/generated/graphql';
|
||||
|
||||
const MULTIPLE_RECORD_PICKER_PAGE_SIZE = 30;
|
||||
|
||||
export const useMultipleRecordPickerPerformSearch = () => {
|
||||
const client = useApolloClient();
|
||||
@ -26,14 +30,40 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
||||
forceSearchFilter = '',
|
||||
forceSearchableObjectMetadataItems = [],
|
||||
forcePickableMorphItems = [],
|
||||
loadMore = false,
|
||||
}: {
|
||||
multipleRecordPickerInstanceId: string;
|
||||
forceSearchFilter?: string;
|
||||
forceSearchableObjectMetadataItems?: ObjectMetadataItem[];
|
||||
forcePickableMorphItems?: RecordPickerPickableMorphItem[];
|
||||
loadMore?: boolean;
|
||||
}) => {
|
||||
const { getLoadable } = snapshot;
|
||||
|
||||
const paginationState = getLoadable(
|
||||
multipleRecordPickerPaginationState.atomFamily({
|
||||
instanceId: multipleRecordPickerInstanceId,
|
||||
}),
|
||||
).getValue();
|
||||
|
||||
set(
|
||||
multipleRecordPickerIsLoadingComponentState.atomFamily({
|
||||
instanceId: multipleRecordPickerInstanceId,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
set(
|
||||
multipleRecordPickerPaginationState.atomFamily({
|
||||
instanceId: multipleRecordPickerInstanceId,
|
||||
}),
|
||||
{
|
||||
...paginationState,
|
||||
endCursor: loadMore ? paginationState.endCursor : null,
|
||||
hasNextPage: loadMore ? paginationState.hasNextPage : true,
|
||||
},
|
||||
);
|
||||
|
||||
const recordPickerSearchFilter = getLoadable(
|
||||
multipleRecordPickerSearchFilterComponentState.atomFamily({
|
||||
instanceId: multipleRecordPickerInstanceId,
|
||||
@ -70,6 +100,7 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
||||
const [
|
||||
searchRecordsFilteredOnPickedRecords,
|
||||
searchRecordsExcludingPickedRecords,
|
||||
pageInfo,
|
||||
] = await performSearchQueries({
|
||||
client,
|
||||
searchFilter,
|
||||
@ -77,28 +108,83 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
||||
pickedRecordIds: selectedPickableMorphItems.map(
|
||||
({ recordId }) => recordId,
|
||||
),
|
||||
after: loadMore ? paginationState.endCursor : null,
|
||||
});
|
||||
|
||||
const pickedMorphItems = pickableMorphItems.filter(
|
||||
({ isSelected }) => isSelected,
|
||||
const existingMorphItems = getLoadable(
|
||||
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
|
||||
instanceId: multipleRecordPickerInstanceId,
|
||||
}),
|
||||
).getValue();
|
||||
|
||||
const allPickedItems = [
|
||||
...existingMorphItems.filter(({ isSelected }) => isSelected),
|
||||
...pickableMorphItems.filter(({ isSelected }) => isSelected),
|
||||
];
|
||||
|
||||
const uniquePickedItems = allPickedItems.reduce(
|
||||
(acc, item) => {
|
||||
if (!acc.some((existing) => existing.recordId === item.recordId)) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof allPickedItems,
|
||||
);
|
||||
|
||||
// We update the existing pickedMorphItems to be matching the search filter
|
||||
const updatedPickedMorphItems = pickedMorphItems.map((morphItem) => {
|
||||
const record = searchRecordsFilteredOnPickedRecords.find(
|
||||
({ recordId }) => recordId === morphItem.recordId,
|
||||
);
|
||||
const updatedPickedItems = uniquePickedItems.map((morphItem) => {
|
||||
if (!searchFilter) {
|
||||
return {
|
||||
...morphItem,
|
||||
isMatchingSearchFilter: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isMatchingSearchFilter =
|
||||
searchRecordsFilteredOnPickedRecords.some(
|
||||
({ recordId }) => recordId === morphItem.recordId,
|
||||
) ||
|
||||
searchRecordsExcludingPickedRecords.some(
|
||||
({ recordId }) => recordId === morphItem.recordId,
|
||||
);
|
||||
|
||||
return {
|
||||
...morphItem,
|
||||
isMatchingSearchFilter: isDefined(record),
|
||||
isMatchingSearchFilter,
|
||||
};
|
||||
});
|
||||
|
||||
const updatedNonPickedExistingItems = existingMorphItems
|
||||
.filter((item) => !item.isSelected)
|
||||
.map((morphItem) => {
|
||||
if (!searchFilter) {
|
||||
return {
|
||||
...morphItem,
|
||||
isMatchingSearchFilter: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isMatchingSearchFilter =
|
||||
searchRecordsFilteredOnPickedRecords.some(
|
||||
({ recordId }) => recordId === morphItem.recordId,
|
||||
) ||
|
||||
searchRecordsExcludingPickedRecords.some(
|
||||
({ recordId }) => recordId === morphItem.recordId,
|
||||
);
|
||||
|
||||
return {
|
||||
...morphItem,
|
||||
isMatchingSearchFilter,
|
||||
};
|
||||
});
|
||||
|
||||
const searchRecordsFilteredOnPickedRecordsWithoutDuplicates =
|
||||
searchRecordsFilteredOnPickedRecords.filter(
|
||||
(searchRecord) =>
|
||||
!updatedPickedMorphItems.some(
|
||||
!updatedPickedItems.some(
|
||||
({ recordId }) => recordId === searchRecord.recordId,
|
||||
) &&
|
||||
!updatedNonPickedExistingItems.some(
|
||||
({ recordId }) => recordId === searchRecord.recordId,
|
||||
),
|
||||
);
|
||||
@ -109,13 +195,17 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
||||
!searchRecordsFilteredOnPickedRecords.some(
|
||||
({ recordId }) => recordId === searchRecord.recordId,
|
||||
) &&
|
||||
!pickedMorphItems.some(
|
||||
!updatedPickedItems.some(
|
||||
({ recordId }) => recordId === searchRecord.recordId,
|
||||
) &&
|
||||
!updatedNonPickedExistingItems.some(
|
||||
({ recordId }) => recordId === searchRecord.recordId,
|
||||
),
|
||||
);
|
||||
|
||||
const morphItems = [
|
||||
...updatedPickedMorphItems,
|
||||
const newMorphItems = [
|
||||
...updatedPickedItems,
|
||||
...updatedNonPickedExistingItems,
|
||||
...searchRecordsFilteredOnPickedRecordsWithoutDuplicates.map(
|
||||
({ recordId, objectNameSingular }) => ({
|
||||
isMatchingSearchFilter: true,
|
||||
@ -140,6 +230,20 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
||||
),
|
||||
];
|
||||
|
||||
const morphItems = loadMore
|
||||
? newMorphItems.reduce(
|
||||
(acc, item) => {
|
||||
if (
|
||||
!acc.some((existing) => existing.recordId === item.recordId)
|
||||
) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof newMorphItems,
|
||||
)
|
||||
: newMorphItems;
|
||||
|
||||
set(
|
||||
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
|
||||
instanceId: multipleRecordPickerInstanceId,
|
||||
@ -234,6 +338,24 @@ export const useMultipleRecordPickerPerformSearch = () => {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
set(
|
||||
multipleRecordPickerPaginationState.atomFamily({
|
||||
instanceId: multipleRecordPickerInstanceId,
|
||||
}),
|
||||
{
|
||||
...paginationState,
|
||||
endCursor: pageInfo.endCursor,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
},
|
||||
);
|
||||
|
||||
set(
|
||||
multipleRecordPickerIsLoadingComponentState.atomFamily({
|
||||
instanceId: multipleRecordPickerInstanceId,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
},
|
||||
[client, performCombinedFindManyRecords],
|
||||
);
|
||||
@ -246,32 +368,46 @@ const performSearchQueries = async ({
|
||||
searchFilter,
|
||||
searchableObjectMetadataItems,
|
||||
pickedRecordIds,
|
||||
limit = MULTIPLE_RECORD_PICKER_PAGE_SIZE,
|
||||
after = null,
|
||||
}: {
|
||||
client: ApolloClient<object>;
|
||||
searchFilter: string;
|
||||
searchableObjectMetadataItems: ObjectMetadataItem[];
|
||||
pickedRecordIds: string[];
|
||||
}): Promise<[SearchRecord[], SearchRecord[]]> => {
|
||||
limit?: number;
|
||||
after?: string | null;
|
||||
}): Promise<
|
||||
[
|
||||
SearchRecord[],
|
||||
SearchRecord[],
|
||||
{ hasNextPage: boolean; endCursor: string | null },
|
||||
]
|
||||
> => {
|
||||
if (searchableObjectMetadataItems.length === 0) {
|
||||
return [[], []];
|
||||
return [[], [], { hasNextPage: false, endCursor: null }];
|
||||
}
|
||||
|
||||
const searchRecords = async (filter: any) => {
|
||||
const { data } = await client.query({
|
||||
query: search,
|
||||
query: SEARCH_QUERY,
|
||||
variables: {
|
||||
searchInput: searchFilter,
|
||||
includedObjectNameSingulars: searchableObjectMetadataItems.map(
|
||||
({ nameSingular }) => nameSingular,
|
||||
),
|
||||
filter,
|
||||
limit: MAX_SEARCH_RESULTS,
|
||||
limit,
|
||||
after,
|
||||
},
|
||||
});
|
||||
return data.search;
|
||||
return {
|
||||
records: data.search.edges.map((edge: SearchResultEdge) => edge.node),
|
||||
pageInfo: data.search.pageInfo,
|
||||
};
|
||||
};
|
||||
|
||||
const searchRecordsExcludingPickedRecords = await searchRecords(
|
||||
const searchRecordsExcludingPickedRecordsResult = await searchRecords(
|
||||
pickedRecordIds.length > 0
|
||||
? {
|
||||
not: {
|
||||
@ -283,17 +419,18 @@ const performSearchQueries = async ({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const searchRecordsIncludingPickedRecords =
|
||||
const searchRecordsIncludingPickedRecordsResult =
|
||||
pickedRecordIds.length > 0
|
||||
? await searchRecords({
|
||||
id: {
|
||||
in: pickedRecordIds,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
: { records: [], pageInfo: { hasNextPage: false, endCursor: null } };
|
||||
|
||||
return [
|
||||
searchRecordsIncludingPickedRecords,
|
||||
searchRecordsExcludingPickedRecords,
|
||||
searchRecordsIncludingPickedRecordsResult.records,
|
||||
searchRecordsExcludingPickedRecordsResult.records,
|
||||
searchRecordsExcludingPickedRecordsResult.pageInfo,
|
||||
];
|
||||
};
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
|
||||
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
|
||||
|
||||
export type MultipleRecordPickerPaginationState = {
|
||||
endCursor: string | null;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
|
||||
export const multipleRecordPickerPaginationState =
|
||||
createComponentStateV2<MultipleRecordPickerPaginationState>({
|
||||
key: 'multipleRecordPickerPaginationState',
|
||||
defaultValue: {
|
||||
endCursor: null,
|
||||
hasNextPage: false,
|
||||
},
|
||||
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
|
||||
import { multipleRecordPickerPaginationState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState';
|
||||
import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2';
|
||||
|
||||
export const multipleRecordPickerPaginationSelector = createComponentSelectorV2(
|
||||
{
|
||||
key: 'multipleRecordPickerPaginationSelector',
|
||||
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
|
||||
get:
|
||||
({ instanceId }) =>
|
||||
({ get }) => {
|
||||
return get(
|
||||
multipleRecordPickerPaginationState.atomFamily({
|
||||
instanceId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user