Fixed record picker loading flickering (#12736)

This PR solves a flickering effect on record pickers on the different
loading state they can be in.

It was designed with @Bonapara to settle on a nice UX feeling.

## Before

With fast network (local) :


https://github.com/user-attachments/assets/58899934-c705-4b44-b7f6-289045032c11

With slow network : 


https://github.com/user-attachments/assets/9fb18d86-9da6-4e5d-a83f-00c810fab2dc

## After


https://github.com/user-attachments/assets/f4abb40f-5d42-4c46-88ab-aaef4f883f7f

Fixes https://github.com/twentyhq/twenty/issues/12680

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-06-24 12:15:50 +02:00
committed by GitHub
parent 9aaa104ec0
commit 3cee2b796f
26 changed files with 475 additions and 196 deletions

View File

@ -1,6 +1,7 @@
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject'; import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; 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 { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
@ -19,6 +20,7 @@ type OpenActivityTargetCellEditModeProps = {
export const useOpenActivityTargetCellEditMode = () => { export const useOpenActivityTargetCellEditMode = () => {
const { performSearch: multipleRecordPickerPerformSearch } = const { performSearch: multipleRecordPickerPerformSearch } =
useMultipleRecordPickerPerformSearch(); useMultipleRecordPickerPerformSearch();
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
@ -70,6 +72,8 @@ export const useOpenActivityTargetCellEditMode = () => {
'', '',
); );
openMultipleRecordPicker(recordPickerInstanceId);
multipleRecordPickerPerformSearch({ multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: recordPickerInstanceId, multipleRecordPickerInstanceId: recordPickerInstanceId,
forceSearchFilter: '', forceSearchFilter: '',
@ -97,7 +101,11 @@ export const useOpenActivityTargetCellEditMode = () => {
memoizeKey: recordPickerInstanceId, memoizeKey: recordPickerInstanceId,
}); });
}, },
[multipleRecordPickerPerformSearch, pushFocusItemToFocusStack], [
multipleRecordPickerPerformSearch,
openMultipleRecordPicker,
pushFocusItemToFocusStack,
],
); );
return { openActivityTargetCellEditMode }; return { openActivityTargetCellEditMode };

View File

@ -4,6 +4,7 @@ import {
FieldRelationFromManyValue, FieldRelationFromManyValue,
FieldRelationValue, FieldRelationValue,
} from '@/object-record/record-field/types/FieldMetadata'; } from '@/object-record/record-field/types/FieldMetadata';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState'; import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
@ -17,6 +18,7 @@ import { useRecoilCallback } from 'recoil';
export const useOpenRelationFromManyFieldInput = () => { export const useOpenRelationFromManyFieldInput = () => {
const { performSearch } = useMultipleRecordPickerPerformSearch(); const { performSearch } = useMultipleRecordPickerPerformSearch();
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
@ -58,6 +60,8 @@ export const useOpenRelationFromManyFieldInput = () => {
return; return;
} }
openMultipleRecordPicker(recordPickerInstanceId);
const pickableMorphItems: RecordPickerPickableMorphItem[] = const pickableMorphItems: RecordPickerPickableMorphItem[] =
fieldValue.map((record) => { fieldValue.map((record) => {
return { return {
@ -105,7 +109,7 @@ export const useOpenRelationFromManyFieldInput = () => {
memoizeKey: recordPickerInstanceId, memoizeKey: recordPickerInstanceId,
}); });
}, },
[performSearch, pushFocusItemToFocusStack], [openMultipleRecordPicker, performSearch, pushFocusItemToFocusStack],
); );
return { openRelationFromManyFieldInput }; return { openRelationFromManyFieldInput };

View File

@ -3,6 +3,7 @@ import {
FieldRelationToOneValue, FieldRelationToOneValue,
FieldRelationValue, FieldRelationValue,
} from '@/object-record/record-field/types/FieldMetadata'; } from '@/object-record/record-field/types/FieldMetadata';
import { useSingleRecordPickerOpen } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerOpen';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState'; import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope'; import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
@ -13,6 +14,7 @@ import { isDefined } from 'twenty-shared/utils';
export const useOpenRelationToOneFieldInput = () => { export const useOpenRelationToOneFieldInput = () => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { openSingleRecordPicker } = useSingleRecordPickerOpen();
const openRelationToOneFieldInput = useRecoilCallback( const openRelationToOneFieldInput = useRecoilCallback(
({ set, snapshot }) => ({ set, snapshot }) =>
@ -39,6 +41,8 @@ export const useOpenRelationToOneFieldInput = () => {
); );
} }
openSingleRecordPicker(recordPickerInstanceId);
pushFocusItemToFocusStack({ pushFocusItemToFocusStack({
focusId: recordPickerInstanceId, focusId: recordPickerInstanceId,
component: { component: {
@ -50,7 +54,7 @@ export const useOpenRelationToOneFieldInput = () => {
memoizeKey: recordPickerInstanceId, memoizeKey: recordPickerInstanceId,
}); });
}, },
[pushFocusItemToFocusStack], [openSingleRecordPicker, pushFocusItemToFocusStack],
); );
return { openRelationToOneFieldInput }; return { openRelationToOneFieldInput };

View File

@ -0,0 +1,8 @@
import styled from '@emotion/styled';
const StyledRecordPickerInitialLoadingEmptyContainer = styled.div`
height: 320px;
width: 100%;
`;
export { StyledRecordPickerInitialLoadingEmptyContainer as RecordPickerInitialLoadingEmptyContainer };

View File

@ -0,0 +1,13 @@
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
export const RecordPickerLoadingSkeletonList = () => {
return (
<>
<DropdownMenuSkeletonItem width="53%" />
<DropdownMenuSkeletonItem width="35%" />
<DropdownMenuSkeletonItem width="48%" />
<DropdownMenuSkeletonItem width="67%" />
<DropdownMenuSkeletonItem width="75%" />
</>
);
};

View File

@ -12,23 +12,16 @@ import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNew
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope'; import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import styled from '@emotion/styled';
import { useRef } from 'react'; import { useRef } from 'react';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display'; import { IconPlus } from 'twenty-ui/display';
export const StyledSelectableItem = styled(SelectableListItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerProps = { type MultipleRecordPickerProps = {
onChange?: (morphItem: RecordPickerPickableMorphItem) => void; onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
onSubmit?: () => void; onSubmit?: () => void;

View File

@ -1,10 +1,16 @@
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; 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 { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerIsFetchingMoreComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsFetchingMoreComponentState';
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState'; 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 { multipleRecordPickerPaginationState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPaginationState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState'; import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerShouldShowInitialLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerShouldShowInitialLoadingComponentState';
import { multipleRecordPickerShouldShowSkeletonComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerShouldShowSkeletonComponentState';
import { multipleRecordPickerPaginationSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPaginationSelector'; import { multipleRecordPickerPaginationSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPaginationSelector';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useCallback } from 'react'; import { useCallback } from 'react';
@ -23,10 +29,17 @@ const StyledText = styled.div`
`; `;
const StyledIntersectionObserver = styled.div` const StyledIntersectionObserver = styled.div`
height: 1px; height: 0px;
`; `;
export const MultipleRecordPickerFetchMoreLoader = () => { export const MultipleRecordPickerFetchMoreLoader = () => {
const [
multipleRecordPickerIsFetchingMore,
setMultipleRecordPickerIsFetchingMore,
] = useRecoilComponentStateV2(
multipleRecordPickerIsFetchingMoreComponentState,
);
const componentInstanceId = useAvailableComponentInstanceIdOrThrow( const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext, MultipleRecordPickerComponentInstanceContext,
); );
@ -46,6 +59,15 @@ export const MultipleRecordPickerFetchMoreLoader = () => {
componentInstanceId, componentInstanceId,
); );
const multipleRecordPickerShouldShowInitialLoading =
useRecoilComponentValueV2(
multipleRecordPickerShouldShowInitialLoadingComponentState,
);
const multipleRecordPickerShouldShowSkeleton = useRecoilComponentValueV2(
multipleRecordPickerShouldShowSkeletonComponentState,
);
const { performSearch } = useMultipleRecordPickerPerformSearch(); const { performSearch } = useMultipleRecordPickerPerformSearch();
const fetchMore = useRecoilCallback( const fetchMore = useRecoilCallback(
@ -63,7 +85,7 @@ export const MultipleRecordPickerFetchMoreLoader = () => {
return; return;
} }
performSearch({ await performSearch({
multipleRecordPickerInstanceId: componentInstanceId, multipleRecordPickerInstanceId: componentInstanceId,
forceSearchFilter: searchFilter, forceSearchFilter: searchFilter,
loadMore: true, loadMore: true,
@ -74,23 +96,34 @@ export const MultipleRecordPickerFetchMoreLoader = () => {
const { ref } = useInView({ const { ref } = useInView({
onChange: useCallback( onChange: useCallback(
(inView: boolean) => { async (inView: boolean) => {
if (inView) { if (inView) {
fetchMore(); setMultipleRecordPickerIsFetchingMore(true);
await fetchMore();
setMultipleRecordPickerIsFetchingMore(false);
} }
}, },
[fetchMore], [fetchMore, setMultipleRecordPickerIsFetchingMore],
), ),
}); });
if (!paginationState.hasNextPage) { if (
!paginationState.hasNextPage ||
multipleRecordPickerShouldShowInitialLoading ||
multipleRecordPickerShouldShowSkeleton ||
(isLoading && !multipleRecordPickerIsFetchingMore)
) {
return null; return null;
} }
return ( return (
<div> <>
<StyledIntersectionObserver ref={ref} /> <StyledIntersectionObserver ref={ref} />
{isLoading && <StyledText>Loading more...</StyledText>} {multipleRecordPickerIsFetchingMore && (
</div> <StyledText>Loading more...</StyledText>
)}
</>
); );
}; };

View File

@ -1,12 +1,7 @@
import { MultipleRecordPickerLoadingEffect } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerLoadingEffect';
import { MultipleRecordPickerMenuItems } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerMenuItems'; 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 { 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 { 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 = ({ export const MultipleRecordPickerItemsDisplay = ({
onChange, onChange,
@ -15,28 +10,11 @@ export const MultipleRecordPickerItemsDisplay = ({
onChange?: (morphItem: RecordPickerPickableMorphItem) => void; onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
focusId: string; focusId: string;
}) => { }) => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext,
);
const isLoading = useRecoilComponentValueV2(
multipleRecordPickerIsLoadingComponentState,
componentInstanceId,
);
const itemsLength = useRecoilComponentValueV2(
multipleRecordPickerPickableMorphItemsLengthComponentSelector,
componentInstanceId,
);
return ( return (
<> <>
<MultipleRecordPickerLoadingEffect />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{isLoading && itemsLength === 0 ? ( <MultipleRecordPickerMenuItems onChange={onChange} focusId={focusId} />
<DropdownMenuSkeletonItem />
) : (
<MultipleRecordPickerMenuItems onChange={onChange} focusId={focusId} />
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
); );

View File

@ -0,0 +1,52 @@
import { multipleRecordPickerIsFetchingMoreComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsFetchingMoreComponentState';
import { multipleRecordPickerIsLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsLoadingComponentState';
import { multipleRecordPickerShouldShowSkeletonComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerShouldShowSkeletonComponentState';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
export const MultipleRecordPickerLoadingEffect = () => {
const [previousLoading, setPreviousLoading] = useState(false);
const loading = useRecoilComponentValueV2(
multipleRecordPickerIsLoadingComponentState,
);
const setMultipleRecordPickerShowSkeleton = useSetRecoilComponentStateV2(
multipleRecordPickerShouldShowSkeletonComponentState,
);
const [multipleRecordPickerIsFetchingMore] = useRecoilComponentStateV2(
multipleRecordPickerIsFetchingMoreComponentState,
);
const debouncedShowPickerSearchSkeleton = useDebouncedCallback(
() => setMultipleRecordPickerShowSkeleton(true),
350,
);
useEffect(() => {
if (previousLoading !== loading) {
setPreviousLoading(loading);
if (loading) {
if (!multipleRecordPickerIsFetchingMore) {
debouncedShowPickerSearchSkeleton();
}
} else {
debouncedShowPickerSearchSkeleton.cancel();
setMultipleRecordPickerShowSkeleton(false);
}
}
}, [
loading,
previousLoading,
setMultipleRecordPickerShowSkeleton,
multipleRecordPickerIsFetchingMore,
debouncedShowPickerSearchSkeleton,
]);
return null;
};

View File

@ -1,28 +1,24 @@
import styled from '@emotion/styled'; import { RecordPickerInitialLoadingEmptyContainer } from '@/object-record/record-picker/components/RecordPickerInitialLoadingEmptyContainer';
import { RecordPickerLoadingSkeletonList } from '@/object-record/record-picker/components/RecordPickerLoadingSkeletonList';
import { RecordPickerNoRecordFoundMenuItem } from '@/object-record/record-picker/components/RecordPickerNoRecordFoundMenuItem'; import { RecordPickerNoRecordFoundMenuItem } from '@/object-record/record-picker/components/RecordPickerNoRecordFoundMenuItem';
import { MultipleRecordPickerFetchMoreLoader } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerFetchMoreLoader'; 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 { 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 { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerShouldShowInitialLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerShouldShowInitialLoadingComponentState';
import { multipleRecordPickerShouldShowSkeletonComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerShouldShowSkeletonComponentState';
import { multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector'; import { multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector } from '@/object-record/record-picker/multiple-record-picker/states/selectors/multipleRecordPickerPickableRecordIdsMatchingSearchComponentSelector';
import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId'; import { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope'; import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
export const StyledSelectableItem = styled(SelectableListItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerMenuItemsProps = { type MultipleRecordPickerMenuItemsProps = {
onChange?: (morphItem: RecordPickerPickableMorphItem) => void; onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
focusId: string; focusId: string;
@ -79,9 +75,24 @@ export const MultipleRecordPickerMenuItems = ({
[multipleRecordPickerPickableMorphItemsState], [multipleRecordPickerPickableMorphItemsState],
); );
const multipleRecordPickerShouldShowInitialLoading =
useRecoilComponentValueV2(
multipleRecordPickerShouldShowInitialLoadingComponentState,
);
const multipleRecordPickerShouldShowSkeleton = useRecoilComponentValueV2(
multipleRecordPickerShouldShowSkeletonComponentState,
);
const searchHasNoResults = pickableRecordIds.length === 0;
return ( return (
<DropdownMenuItemsContainer hasMaxHeight> <DropdownMenuItemsContainer hasMaxHeight>
{pickableRecordIds.length === 0 ? ( {multipleRecordPickerShouldShowInitialLoading ? (
<RecordPickerInitialLoadingEmptyContainer />
) : multipleRecordPickerShouldShowSkeleton ? (
<RecordPickerLoadingSkeletonList />
) : searchHasNoResults ? (
<RecordPickerNoRecordFoundMenuItem /> <RecordPickerNoRecordFoundMenuItem />
) : ( ) : (
<SelectableList <SelectableList

View File

@ -0,0 +1,40 @@
import { multipleRecordPickerShouldShowInitialLoadingComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerShouldShowInitialLoadingComponentState';
import { multipleRecordPickerShouldShowSkeletonComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerShouldShowSkeletonComponentState';
import { useRecoilCallback } from 'recoil';
export const useMultipleRecordPickerOpen = () => {
const openMultipleRecordPicker = useRecoilCallback(
({ set }) =>
(recordPickerComponentInstanceId: string) => {
set(
multipleRecordPickerShouldShowInitialLoadingComponentState.atomFamily(
{
instanceId: recordPickerComponentInstanceId,
},
),
true,
);
set(
multipleRecordPickerShouldShowSkeletonComponentState.atomFamily({
instanceId: recordPickerComponentInstanceId,
}),
true,
);
setTimeout(() => {
set(
multipleRecordPickerShouldShowInitialLoadingComponentState.atomFamily(
{
instanceId: recordPickerComponentInstanceId,
},
),
false,
);
}, 100);
},
[],
);
return {
openMultipleRecordPicker,
};
};

View File

@ -0,0 +1,9 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const multipleRecordPickerIsFetchingMoreComponentState =
createComponentStateV2<boolean>({
key: 'multipleRecordPickerIsFetchingMoreComponentState',
defaultValue: false,
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const multipleRecordPickerShouldShowInitialLoadingComponentState =
createComponentStateV2<boolean>({
key: 'multipleRecordPickerShouldShowInitialLoadingComponentState',
defaultValue: false,
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { MultipleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/multiple-record-picker/states/contexts/MultipleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const multipleRecordPickerShouldShowSkeletonComponentState =
createComponentStateV2<boolean>({
key: 'multipleRecordPickerShouldShowSkeletonComponentState',
defaultValue: false,
componentInstanceContext: MultipleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,40 @@
import { singleRecordPickerShouldShowSkeletonComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerShouldShowSkeletonComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useEffect, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
export const SingleRecordPickerLoadingEffect = ({
loading,
}: {
loading: boolean;
}) => {
const [previousLoading, setPreviousLoading] = useState(false);
const setSingleRecordPickerShouldShowSkeleton = useSetRecoilComponentStateV2(
singleRecordPickerShouldShowSkeletonComponentState,
);
const debouncedShowPickerSearchSkeleton = useDebouncedCallback(() => {
setSingleRecordPickerShouldShowSkeleton(true);
}, 350);
useEffect(() => {
if (previousLoading !== loading) {
setPreviousLoading(loading);
if (loading) {
debouncedShowPickerSearchSkeleton();
} else {
debouncedShowPickerSearchSkeleton.cancel();
setSingleRecordPickerShouldShowSkeleton(false);
}
}
}, [
loading,
previousLoading,
debouncedShowPickerSearchSkeleton,
setSingleRecordPickerShouldShowSkeleton,
]);
return null;
};

View File

@ -1,10 +1,12 @@
import { isNonEmptyString, isUndefined } from '@sniptt/guards'; import { isNonEmptyString, isUndefined } from '@sniptt/guards';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { RecordPickerInitialLoadingEmptyContainer } from '@/object-record/record-picker/components/RecordPickerInitialLoadingEmptyContainer';
import { RecordPickerLoadingSkeletonList } from '@/object-record/record-picker/components/RecordPickerLoadingSkeletonList';
import { RecordPickerNoRecordFoundMenuItem } from '@/object-record/record-picker/components/RecordPickerNoRecordFoundMenuItem';
import { SingleRecordPickerMenuItem } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItem'; import { SingleRecordPickerMenuItem } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItem';
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext'; import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState'; import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
@ -17,44 +19,35 @@ import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotke
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { IconComponent } from 'twenty-ui/display'; import { IconComponent } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation'; import { MenuItemSelect } from 'twenty-ui/navigation';
import { singleRecordPickerShouldShowInitialLoadingComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerShouldShowInitialLoadingComponentState';
import { singleRecordPickerShouldShowSkeletonComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerShouldShowSkeletonComponentState';
export type SingleRecordPickerMenuItemsProps = { export type SingleRecordPickerMenuItemsProps = {
EmptyIcon?: IconComponent; EmptyIcon?: IconComponent;
emptyLabel?: string; emptyLabel?: string;
recordsToSelect: SingleRecordPickerRecord[]; recordsToSelect: SingleRecordPickerRecord[];
loading?: boolean;
onCancel?: () => void; onCancel?: () => void;
onRecordSelected: (entity?: SingleRecordPickerRecord) => void; onRecordSelected: (entity?: SingleRecordPickerRecord) => void;
selectedRecord?: SingleRecordPickerRecord; selectedRecord?: SingleRecordPickerRecord;
focusId: string; focusId: string;
filteredSelectedRecords: SingleRecordPickerRecord[];
}; };
export const SingleRecordPickerMenuItems = ({ export const SingleRecordPickerMenuItems = ({
EmptyIcon, EmptyIcon,
emptyLabel, emptyLabel,
recordsToSelect, recordsToSelect,
loading,
onCancel, onCancel,
onRecordSelected, onRecordSelected,
filteredSelectedRecords,
selectedRecord, selectedRecord,
focusId, focusId,
}: SingleRecordPickerMenuItemsProps) => { }: SingleRecordPickerMenuItemsProps) => {
const selectNone = emptyLabel const recordsInDropdown = [selectedRecord, ...recordsToSelect].filter(
? {
__typename: '',
id: 'select-none',
name: emptyLabel,
}
: null;
const recordsInDropdown = [
selectNone,
selectedRecord,
...recordsToSelect,
].filter(
(entity): entity is SingleRecordPickerRecord => (entity): entity is SingleRecordPickerRecord =>
isDefined(entity) && isNonEmptyString(entity.name), isDefined(entity) && isNonEmptyString(entity.name),
); );
@ -93,6 +86,17 @@ export const SingleRecordPickerMenuItems = ({
singleRecordPickerSelectedIdComponentState, singleRecordPickerSelectedIdComponentState,
); );
const singleRecordPickerShouldShowSkeleton = useRecoilComponentValueV2(
singleRecordPickerShouldShowSkeletonComponentState,
);
const singleRecordPickerShouldShowInitialLoading = useRecoilComponentValueV2(
singleRecordPickerShouldShowInitialLoadingComponentState,
);
const searchHasNoResults =
recordsToSelect.length === 0 && filteredSelectedRecords?.length === 0;
return ( return (
<SelectableList <SelectableList
selectableListInstanceId={selectableListComponentInstanceId} selectableListInstanceId={selectableListComponentInstanceId}
@ -100,49 +104,42 @@ export const SingleRecordPickerMenuItems = ({
hotkeyScope={DropdownHotkeyScope.Dropdown} hotkeyScope={DropdownHotkeyScope.Dropdown}
focusId={focusId} focusId={focusId}
> >
{loading ? ( {emptyLabel && (
<DropdownMenuSkeletonItem /> <SelectableListItem
) : ( key={'select-none'}
recordsInDropdown?.map((record) => { itemId={'select-none'}
switch (record.id) { onEnter={() => {
case 'select-none': { setSelectedRecordId(undefined);
return ( onRecordSelected();
emptyLabel && ( }}
<SelectableListItem >
key={record.id} <MenuItemSelect
itemId={record.id} onClick={() => {
onEnter={() => { setSelectedRecordId(undefined);
setSelectedRecordId(undefined); onRecordSelected();
onRecordSelected(); }}
}} LeftIcon={EmptyIcon}
> text={emptyLabel}
<MenuItemSelect selected={isUndefined(selectedRecordId)}
onClick={() => { focused={isSelectedSelectNoneButton}
setSelectedRecordId(undefined); />
onRecordSelected(); </SelectableListItem>
}}
LeftIcon={EmptyIcon}
text={emptyLabel}
selected={isUndefined(selectedRecordId)}
focused={isSelectedSelectNoneButton}
/>
</SelectableListItem>
)
);
}
default: {
return (
<SingleRecordPickerMenuItem
key={record.id}
record={record}
onRecordSelected={onRecordSelected}
selectedRecord={selectedRecord}
/>
);
}
}
})
)} )}
{singleRecordPickerShouldShowInitialLoading ? (
<RecordPickerInitialLoadingEmptyContainer />
) : singleRecordPickerShouldShowSkeleton ? (
<RecordPickerLoadingSkeletonList />
) : (
recordsInDropdown?.map((record) => (
<SingleRecordPickerMenuItem
key={record.id}
record={record}
onRecordSelected={onRecordSelected}
selectedRecord={selectedRecord}
/>
))
)}
{searchHasNoResults && <RecordPickerNoRecordFoundMenuItem />}
</SelectableList> </SelectableList>
); );
}; };

View File

@ -1,6 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { RecordPickerNoRecordFoundMenuItem } from '@/object-record/record-picker/components/RecordPickerNoRecordFoundMenuItem'; import { SingleRecordPickerLoadingEffect } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerLoadingEffect';
import { import {
SingleRecordPickerMenuItems, SingleRecordPickerMenuItems,
SingleRecordPickerMenuItemsProps, SingleRecordPickerMenuItemsProps,
@ -16,7 +16,6 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display'; import { IconPlus } from 'twenty-ui/display';
@ -73,18 +72,13 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords; const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const searchHasNoResults =
isNonEmptyString(recordPickerSearchFilter) &&
records.recordsToSelect.length === 0 &&
records.filteredSelectedRecords.length === 0 &&
!records.loading;
const handleCreateNew = () => { const handleCreateNew = () => {
onCreate?.(recordPickerSearchFilter); onCreate?.(recordPickerSearchFilter);
}; };
return ( return (
<> <>
<SingleRecordPickerLoadingEffect loading={records.loading} />
{layoutDirection === 'search-bar-on-bottom' && ( {layoutDirection === 'search-bar-on-bottom' && (
<> <>
{isDefined(onCreate) && hasObjectUpdatePermissions && ( {isDefined(onCreate) && hasObjectUpdatePermissions && (
@ -101,12 +95,11 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
)} )}
<DropdownMenuItemsContainer hasMaxHeight> <DropdownMenuItemsContainer hasMaxHeight>
{searchHasNoResults && <RecordPickerNoRecordFoundMenuItem />}
<SingleRecordPickerMenuItems <SingleRecordPickerMenuItems
focusId={focusId} focusId={focusId}
recordsToSelect={records.recordsToSelect} recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={records.selectedRecords?.[0]} selectedRecord={records.selectedRecords?.[0]}
filteredSelectedRecords={records.filteredSelectedRecords}
{...{ {...{
EmptyIcon, EmptyIcon,
emptyLabel, emptyLabel,
@ -130,8 +123,8 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
<SingleRecordPickerMenuItems <SingleRecordPickerMenuItems
focusId={focusId} focusId={focusId}
recordsToSelect={records.recordsToSelect} recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={records.selectedRecords?.[0]} selectedRecord={records.selectedRecords?.[0]}
filteredSelectedRecords={records.filteredSelectedRecords}
{...{ {...{
EmptyIcon, EmptyIcon,
emptyLabel, emptyLabel,
@ -139,7 +132,6 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
onRecordSelected, onRecordSelected,
}} }}
/> />
{searchHasNoResults && <RecordPickerNoRecordFoundMenuItem />}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
{isDefined(onCreate) && hasObjectUpdatePermissions && ( {isDefined(onCreate) && hasObjectUpdatePermissions && (
<> <>

View File

@ -0,0 +1,38 @@
import { singleRecordPickerShouldShowInitialLoadingComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerShouldShowInitialLoadingComponentState';
import { singleRecordPickerShouldShowSkeletonComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerShouldShowSkeletonComponentState';
import { useRecoilCallback } from 'recoil';
export const useSingleRecordPickerOpen = () => {
const openSingleRecordPicker = useRecoilCallback(
({ set }) =>
(recordPickerComponentInstanceId: string) => {
set(
singleRecordPickerShouldShowInitialLoadingComponentState.atomFamily({
instanceId: recordPickerComponentInstanceId,
}),
true,
);
set(
singleRecordPickerShouldShowSkeletonComponentState.atomFamily({
instanceId: recordPickerComponentInstanceId,
}),
true,
);
setTimeout(() => {
set(
singleRecordPickerShouldShowInitialLoadingComponentState.atomFamily(
{
instanceId: recordPickerComponentInstanceId,
},
),
false,
);
}, 100);
},
[],
);
return {
openSingleRecordPicker,
};
};

View File

@ -23,6 +23,7 @@ export const useSingleRecordPickerSearch = (
singleRecordPickerSelectedIdComponentState, singleRecordPickerSelectedIdComponentState,
recordPickerComponentInstanceId, recordPickerComponentInstanceId,
); );
const debouncedSetSearchFilter = useDebouncedCallback( const debouncedSetSearchFilter = useDebouncedCallback(
setRecordPickerSearchFilter, setRecordPickerSearchFilter,
100, 100,

View File

@ -0,0 +1,9 @@
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const singleRecordPickerShouldShowInitialLoadingComponentState =
createComponentStateV2<boolean>({
key: 'singleRecordPickerShouldShowInitialLoadingComponentState',
defaultValue: false,
componentInstanceContext: SingleRecordPickerComponentInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const singleRecordPickerShouldShowSkeletonComponentState =
createComponentStateV2<boolean>({
key: 'singleRecordPickerShouldShowSkeletonComponentState',
defaultValue: false,
componentInstanceContext: SingleRecordPickerComponentInstanceContext,
});

View File

@ -7,6 +7,7 @@ import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/
import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput'; import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker'; import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState'; 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 { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
@ -71,6 +72,8 @@ export const RecordDetailRelationSectionDropdownToMany = () => {
const { performSearch: multipleRecordPickerPerformSearch } = const { performSearch: multipleRecordPickerPerformSearch } =
useMultipleRecordPickerPerformSearch(); useMultipleRecordPickerPerformSearch();
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
const handleCloseRelationPickerDropdown = useCallback(() => { const handleCloseRelationPickerDropdown = useCallback(() => {
setMultipleRecordPickerSearchFilter(''); setMultipleRecordPickerSearchFilter('');
}, [setMultipleRecordPickerSearchFilter]); }, [setMultipleRecordPickerSearchFilter]);
@ -99,6 +102,8 @@ export const RecordDetailRelationSectionDropdownToMany = () => {
})), })),
); );
openMultipleRecordPicker(dropdownId);
multipleRecordPickerPerformSearch({ multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: dropdownId, multipleRecordPickerInstanceId: dropdownId,
forceSearchFilter: '', forceSearchFilter: '',

View File

@ -8,6 +8,7 @@ import { usePersistField } from '@/object-record/record-field/hooks/usePersistFi
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer'; import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker'; import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { useSingleRecordPickerOpen } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerOpen';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState'; import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState'; import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord'; import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
@ -87,8 +88,12 @@ export const RecordDetailRelationSectionDropdownToOne = () => {
recordId, recordId,
}); });
const { openSingleRecordPicker } = useSingleRecordPickerOpen();
const handleOpenRelationPickerDropdown = () => { const handleOpenRelationPickerDropdown = () => {
setSingleRecordPickerSearchFilter(''); setSingleRecordPickerSearchFilter('');
openSingleRecordPicker(dropdownId);
if (relationRecords.length > 0) { if (relationRecords.length > 0) {
setSingleRecordPickerSelectedId(relationRecords[0]?.id); setSingleRecordPickerSelectedId(relationRecords[0]?.id);
} }

View File

@ -1,4 +1,5 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { CSSWidth } from '@/ui/types/CSSWidth';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
@ -6,15 +7,24 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledDropdownMenuSkeletonContainer = styled.div` const StyledDropdownMenuSkeletonContainer = styled.div`
--horizontal-padding: ${({ theme }) => theme.spacing(1)}; --horizontal-padding: ${({ theme }) => theme.spacing(1)};
--vertical-padding: ${({ theme }) => theme.spacing(2)}; --vertical-padding: ${({ theme }) => theme.spacing(2)};
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm}; border-radius: ${({ theme }) => theme.border.radius.sm};
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
height: calc(32px - 2 * var(--vertical-padding)); box-sizing: border-box;
padding: var(--vertical-padding) var(--horizontal-padding);
width: calc(100% - 2 * var(--horizontal-padding)); flex-shrink: 0;
padding-left: var(--horizontal-padding);
padding-right: var(--horizontal-padding);
height: ${({ theme }) => theme.spacing(8)};
`; `;
export const DropdownMenuSkeletonItem = () => { export const DropdownMenuSkeletonItem = ({
width = '100%',
}: {
width?: CSSWidth;
}) => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<StyledDropdownMenuSkeletonContainer> <StyledDropdownMenuSkeletonContainer>
@ -22,7 +32,11 @@ export const DropdownMenuSkeletonItem = () => {
baseColor={theme.background.quaternary} baseColor={theme.background.quaternary}
highlightColor={theme.background.secondary} highlightColor={theme.background.secondary}
> >
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.s} /> <Skeleton
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
style={{ lineHeight: 0 }}
width={width}
/>
</SkeletonTheme> </SkeletonTheme>
</StyledDropdownMenuSkeletonContainer> </StyledDropdownMenuSkeletonContainer>
); );

View File

@ -6,6 +6,7 @@ import { useState } from 'react';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem'; import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { Modal } from '@/ui/layout/modal/components/Modal'; import { Modal } from '@/ui/layout/modal/components/Modal';
import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope'; import { ModalHotkeyScope } from '@/ui/layout/modal/components/types/ModalHotkeyScope';
@ -84,7 +85,9 @@ const StyledEmptyDropdownContent = styled.div`
export const Empty: Story = { export const Empty: Story = {
args: { args: {
dropdownComponents: ( dropdownComponents: (
<StyledEmptyDropdownContent data-testid="dropdown-content" /> <DropdownContent>
<StyledEmptyDropdownContent data-testid="dropdown-content" />
</DropdownContent>
), ),
}, },
play: async () => { play: async () => {
@ -155,26 +158,28 @@ const FakeSelectableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
const [selectedItem, setSelectedItem] = useState<string | null>(null); const [selectedItem, setSelectedItem] = useState<string | null>(null);
return ( return (
<> <DropdownContent>
{optionsMock.map((item) => ( <DropdownMenuItemsContainer hasMaxHeight>
<MenuItemSelectAvatar {optionsMock.map((item) => (
key={item.id} <MenuItemSelectAvatar
selected={selectedItem === item.id} key={item.id}
onClick={() => setSelectedItem(item.id)} selected={selectedItem === item.id}
avatar={ onClick={() => setSelectedItem(item.id)}
hasAvatar ? ( avatar={
<Avatar hasAvatar ? (
placeholder="A" <Avatar
avatarUrl={item.avatarUrl} placeholder="A"
size="md" avatarUrl={item.avatarUrl}
type="squared" size="md"
/> type="squared"
) : undefined />
} ) : undefined
text={item.name} }
/> text={item.name}
))} />
</> ))}
</DropdownMenuItemsContainer>
</DropdownContent>
); );
}; };
@ -184,31 +189,33 @@ const FakeCheckableMenuItemList = ({ hasAvatar }: { hasAvatar?: boolean }) => {
>({}); >({});
return ( return (
<> <DropdownContent>
{optionsMock.map((item) => ( <DropdownMenuItemsContainer hasMaxHeight>
<MenuItemMultiSelectAvatar {optionsMock.map((item) => (
key={item.id} <MenuItemMultiSelectAvatar
selected={selectedItemsById[item.id]} key={item.id}
onSelectChange={(checked) => selected={selectedItemsById[item.id]}
setSelectedItemsById((previous) => ({ onSelectChange={(checked) =>
...previous, setSelectedItemsById((previous) => ({
[item.id]: checked, ...previous,
})) [item.id]: checked,
} }))
avatar={ }
hasAvatar ? ( avatar={
<Avatar hasAvatar ? (
placeholder="A" <Avatar
avatarUrl={item.avatarUrl} placeholder="A"
size="md" avatarUrl={item.avatarUrl}
type="squared" size="md"
/> type="squared"
) : undefined />
} ) : undefined
text={item.name} }
/> text={item.name}
))} />
</> ))}
</DropdownMenuItemsContainer>
</DropdownContent>
); );
}; };
@ -227,7 +234,7 @@ export const WithHeaders: Story = {
decorators: [WithContentBelowDecorator], decorators: [WithContentBelowDecorator],
args: { args: {
dropdownComponents: ( dropdownComponents: (
<> <DropdownContent>
<DropdownMenuHeader <DropdownMenuHeader
StartComponent={ StartComponent={
<DropdownMenuHeaderLeftComponent Icon={IconChevronLeft} /> <DropdownMenuHeaderLeftComponent Icon={IconChevronLeft} />
@ -250,7 +257,7 @@ export const WithHeaders: Story = {
<MenuItem key={item.id} text={item.name} /> <MenuItem key={item.id} text={item.name} />
))} ))}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
</> </DropdownContent>
), ),
}, },
play: playInteraction, play: playInteraction,
@ -260,13 +267,13 @@ export const SearchWithLoadingMenu: Story = {
decorators: [WithContentBelowDecorator], decorators: [WithContentBelowDecorator],
args: { args: {
dropdownComponents: ( dropdownComponents: (
<> <DropdownContent>
<DropdownMenuSearchInput value="query" autoFocus /> <DropdownMenuSearchInput value="query" autoFocus />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight> <DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuSkeletonItem /> <DropdownMenuSkeletonItem />
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
</> </DropdownContent>
), ),
}, },
play: async () => { play: async () => {
@ -292,7 +299,7 @@ export const WithInput: Story = {
decorators: [WithContentBelowDecorator], decorators: [WithContentBelowDecorator],
args: { args: {
dropdownComponents: ( dropdownComponents: (
<> <DropdownContent>
<DropdownMenuInput value="Lorem ipsum" autoFocus /> <DropdownMenuInput value="Lorem ipsum" autoFocus />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight> <DropdownMenuItemsContainer hasMaxHeight>
@ -300,7 +307,7 @@ export const WithInput: Story = {
<MenuItem key={name} text={name} /> <MenuItem key={name} text={name} />
))} ))}
</DropdownMenuItemsContainer> </DropdownMenuItemsContainer>
</> </DropdownContent>
), ),
}, },
play: playInteraction, play: playInteraction,
@ -309,11 +316,7 @@ export const WithInput: Story = {
export const SelectableMenuItemWithAvatar: Story = { export const SelectableMenuItemWithAvatar: Story = {
decorators: [WithContentBelowDecorator], decorators: [WithContentBelowDecorator],
args: { args: {
dropdownComponents: ( dropdownComponents: <FakeSelectableMenuItemList hasAvatar />,
<DropdownMenuItemsContainer hasMaxHeight>
<FakeSelectableMenuItemList hasAvatar />
</DropdownMenuItemsContainer>
),
}, },
play: playInteraction, play: playInteraction,
}; };
@ -321,11 +324,7 @@ export const SelectableMenuItemWithAvatar: Story = {
export const CheckableMenuItemWithAvatar: Story = { export const CheckableMenuItemWithAvatar: Story = {
decorators: [WithContentBelowDecorator], decorators: [WithContentBelowDecorator],
args: { args: {
dropdownComponents: ( dropdownComponents: <FakeCheckableMenuItemList hasAvatar />,
<DropdownMenuItemsContainer hasMaxHeight>
<FakeCheckableMenuItemList hasAvatar />
</DropdownMenuItemsContainer>
),
}, },
play: playInteraction, play: playInteraction,
}; };
@ -354,11 +353,9 @@ const ModalWithDropdown = () => {
dropdownId="modal-dropdown-test" dropdownId="modal-dropdown-test"
isDropdownInModal={true} isDropdownInModal={true}
dropdownComponents={ dropdownComponents={
<DropdownMenuItemsContainer hasMaxHeight> <div data-testid="dropdown-content">
<div data-testid="dropdown-content"> <FakeSelectableMenuItemList hasAvatar />
<FakeSelectableMenuItemList hasAvatar /> </div>
</div>
</DropdownMenuItemsContainer>
} }
/> />
</div> </div>

View File

@ -0,0 +1 @@
export type CSSWidth = `${number}%` | `${number}px`;