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

View File

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

View File

@ -3,6 +3,7 @@ import {
FieldRelationToOneValue,
FieldRelationValue,
} 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 { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
@ -13,6 +14,7 @@ import { isDefined } from 'twenty-shared/utils';
export const useOpenRelationToOneFieldInput = () => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { openSingleRecordPicker } = useSingleRecordPickerOpen();
const openRelationToOneFieldInput = useRecoilCallback(
({ set, snapshot }) =>
@ -39,6 +41,8 @@ export const useOpenRelationToOneFieldInput = () => {
);
}
openSingleRecordPicker(recordPickerInstanceId);
pushFocusItemToFocusStack({
focusId: recordPickerInstanceId,
component: {
@ -50,7 +54,7 @@ export const useOpenRelationToOneFieldInput = () => {
memoizeKey: recordPickerInstanceId,
});
},
[pushFocusItemToFocusStack],
[openSingleRecordPicker, pushFocusItemToFocusStack],
);
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 { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
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 { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import styled from '@emotion/styled';
import { useRef } from 'react';
import { useRecoilCallback } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
export const StyledSelectableItem = styled(SelectableListItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerProps = {
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
onSubmit?: () => void;

View File

@ -1,10 +1,16 @@
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 { multipleRecordPickerIsFetchingMoreComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerIsFetchingMoreComponentState';
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 { 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 { 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 styled from '@emotion/styled';
import { useCallback } from 'react';
@ -23,10 +29,17 @@ const StyledText = styled.div`
`;
const StyledIntersectionObserver = styled.div`
height: 1px;
height: 0px;
`;
export const MultipleRecordPickerFetchMoreLoader = () => {
const [
multipleRecordPickerIsFetchingMore,
setMultipleRecordPickerIsFetchingMore,
] = useRecoilComponentStateV2(
multipleRecordPickerIsFetchingMoreComponentState,
);
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext,
);
@ -46,6 +59,15 @@ export const MultipleRecordPickerFetchMoreLoader = () => {
componentInstanceId,
);
const multipleRecordPickerShouldShowInitialLoading =
useRecoilComponentValueV2(
multipleRecordPickerShouldShowInitialLoadingComponentState,
);
const multipleRecordPickerShouldShowSkeleton = useRecoilComponentValueV2(
multipleRecordPickerShouldShowSkeletonComponentState,
);
const { performSearch } = useMultipleRecordPickerPerformSearch();
const fetchMore = useRecoilCallback(
@ -63,7 +85,7 @@ export const MultipleRecordPickerFetchMoreLoader = () => {
return;
}
performSearch({
await performSearch({
multipleRecordPickerInstanceId: componentInstanceId,
forceSearchFilter: searchFilter,
loadMore: true,
@ -74,23 +96,34 @@ export const MultipleRecordPickerFetchMoreLoader = () => {
const { ref } = useInView({
onChange: useCallback(
(inView: boolean) => {
async (inView: boolean) => {
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 (
<div>
<>
<StyledIntersectionObserver ref={ref} />
{isLoading && <StyledText>Loading more...</StyledText>}
</div>
{multipleRecordPickerIsFetchingMore && (
<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 { 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,
@ -15,28 +10,11 @@ export const MultipleRecordPickerItemsDisplay = ({
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
focusId: string;
}) => {
const componentInstanceId = useAvailableComponentInstanceIdOrThrow(
MultipleRecordPickerComponentInstanceContext,
);
const isLoading = useRecoilComponentValueV2(
multipleRecordPickerIsLoadingComponentState,
componentInstanceId,
);
const itemsLength = useRecoilComponentValueV2(
multipleRecordPickerPickableMorphItemsLengthComponentSelector,
componentInstanceId,
);
return (
<>
<MultipleRecordPickerLoadingEffect />
<DropdownMenuSeparator />
{isLoading && itemsLength === 0 ? (
<DropdownMenuSkeletonItem />
) : (
<MultipleRecordPickerMenuItems onChange={onChange} focusId={focusId} />
)}
<MultipleRecordPickerMenuItems onChange={onChange} focusId={focusId} />
<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 { 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';
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 { getMultipleRecordPickerSelectableListId } from '@/object-record/record-picker/multiple-record-picker/utils/getMultipleRecordPickerSelectableListId';
import { RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownHotkeyScope } from '@/ui/layout/dropdown/constants/DropdownHotkeyScope';
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 { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useRecoilCallback } from 'recoil';
export const StyledSelectableItem = styled(SelectableListItem)`
height: 100%;
width: 100%;
`;
type MultipleRecordPickerMenuItemsProps = {
onChange?: (morphItem: RecordPickerPickableMorphItem) => void;
focusId: string;
@ -79,9 +75,24 @@ export const MultipleRecordPickerMenuItems = ({
[multipleRecordPickerPickableMorphItemsState],
);
const multipleRecordPickerShouldShowInitialLoading =
useRecoilComponentValueV2(
multipleRecordPickerShouldShowInitialLoadingComponentState,
);
const multipleRecordPickerShouldShowSkeleton = useRecoilComponentValueV2(
multipleRecordPickerShouldShowSkeletonComponentState,
);
const searchHasNoResults = pickableRecordIds.length === 0;
return (
<DropdownMenuItemsContainer hasMaxHeight>
{pickableRecordIds.length === 0 ? (
{multipleRecordPickerShouldShowInitialLoading ? (
<RecordPickerInitialLoadingEmptyContainer />
) : multipleRecordPickerShouldShowSkeleton ? (
<RecordPickerLoadingSkeletonList />
) : searchHasNoResults ? (
<RecordPickerNoRecordFoundMenuItem />
) : (
<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 { 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 { 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 { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
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 { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2';
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 { IconComponent } from 'twenty-ui/display';
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 = {
EmptyIcon?: IconComponent;
emptyLabel?: string;
recordsToSelect: SingleRecordPickerRecord[];
loading?: boolean;
onCancel?: () => void;
onRecordSelected: (entity?: SingleRecordPickerRecord) => void;
selectedRecord?: SingleRecordPickerRecord;
focusId: string;
filteredSelectedRecords: SingleRecordPickerRecord[];
};
export const SingleRecordPickerMenuItems = ({
EmptyIcon,
emptyLabel,
recordsToSelect,
loading,
onCancel,
onRecordSelected,
filteredSelectedRecords,
selectedRecord,
focusId,
}: SingleRecordPickerMenuItemsProps) => {
const selectNone = emptyLabel
? {
__typename: '',
id: 'select-none',
name: emptyLabel,
}
: null;
const recordsInDropdown = [
selectNone,
selectedRecord,
...recordsToSelect,
].filter(
const recordsInDropdown = [selectedRecord, ...recordsToSelect].filter(
(entity): entity is SingleRecordPickerRecord =>
isDefined(entity) && isNonEmptyString(entity.name),
);
@ -93,6 +86,17 @@ export const SingleRecordPickerMenuItems = ({
singleRecordPickerSelectedIdComponentState,
);
const singleRecordPickerShouldShowSkeleton = useRecoilComponentValueV2(
singleRecordPickerShouldShowSkeletonComponentState,
);
const singleRecordPickerShouldShowInitialLoading = useRecoilComponentValueV2(
singleRecordPickerShouldShowInitialLoadingComponentState,
);
const searchHasNoResults =
recordsToSelect.length === 0 && filteredSelectedRecords?.length === 0;
return (
<SelectableList
selectableListInstanceId={selectableListComponentInstanceId}
@ -100,49 +104,42 @@ export const SingleRecordPickerMenuItems = ({
hotkeyScope={DropdownHotkeyScope.Dropdown}
focusId={focusId}
>
{loading ? (
<DropdownMenuSkeletonItem />
) : (
recordsInDropdown?.map((record) => {
switch (record.id) {
case 'select-none': {
return (
emptyLabel && (
<SelectableListItem
key={record.id}
itemId={record.id}
onEnter={() => {
setSelectedRecordId(undefined);
onRecordSelected();
}}
>
<MenuItemSelect
onClick={() => {
setSelectedRecordId(undefined);
onRecordSelected();
}}
LeftIcon={EmptyIcon}
text={emptyLabel}
selected={isUndefined(selectedRecordId)}
focused={isSelectedSelectNoneButton}
/>
</SelectableListItem>
)
);
}
default: {
return (
<SingleRecordPickerMenuItem
key={record.id}
record={record}
onRecordSelected={onRecordSelected}
selectedRecord={selectedRecord}
/>
);
}
}
})
{emptyLabel && (
<SelectableListItem
key={'select-none'}
itemId={'select-none'}
onEnter={() => {
setSelectedRecordId(undefined);
onRecordSelected();
}}
>
<MenuItemSelect
onClick={() => {
setSelectedRecordId(undefined);
onRecordSelected();
}}
LeftIcon={EmptyIcon}
text={emptyLabel}
selected={isUndefined(selectedRecordId)}
focused={isSelectedSelectNoneButton}
/>
</SelectableListItem>
)}
{singleRecordPickerShouldShowInitialLoading ? (
<RecordPickerInitialLoadingEmptyContainer />
) : singleRecordPickerShouldShowSkeleton ? (
<RecordPickerLoadingSkeletonList />
) : (
recordsInDropdown?.map((record) => (
<SingleRecordPickerMenuItem
key={record.id}
record={record}
onRecordSelected={onRecordSelected}
selectedRecord={selectedRecord}
/>
))
)}
{searchHasNoResults && <RecordPickerNoRecordFoundMenuItem />}
</SelectableList>
);
};

View File

@ -1,6 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
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 {
SingleRecordPickerMenuItems,
SingleRecordPickerMenuItemsProps,
@ -16,7 +16,6 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
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';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
@ -73,18 +72,13 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const searchHasNoResults =
isNonEmptyString(recordPickerSearchFilter) &&
records.recordsToSelect.length === 0 &&
records.filteredSelectedRecords.length === 0 &&
!records.loading;
const handleCreateNew = () => {
onCreate?.(recordPickerSearchFilter);
};
return (
<>
<SingleRecordPickerLoadingEffect loading={records.loading} />
{layoutDirection === 'search-bar-on-bottom' && (
<>
{isDefined(onCreate) && hasObjectUpdatePermissions && (
@ -101,12 +95,11 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
)}
<DropdownMenuItemsContainer hasMaxHeight>
{searchHasNoResults && <RecordPickerNoRecordFoundMenuItem />}
<SingleRecordPickerMenuItems
focusId={focusId}
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={records.selectedRecords?.[0]}
filteredSelectedRecords={records.filteredSelectedRecords}
{...{
EmptyIcon,
emptyLabel,
@ -130,8 +123,8 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
<SingleRecordPickerMenuItems
focusId={focusId}
recordsToSelect={records.recordsToSelect}
loading={records.loading}
selectedRecord={records.selectedRecords?.[0]}
filteredSelectedRecords={records.filteredSelectedRecords}
{...{
EmptyIcon,
emptyLabel,
@ -139,7 +132,6 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
onRecordSelected,
}}
/>
{searchHasNoResults && <RecordPickerNoRecordFoundMenuItem />}
</DropdownMenuItemsContainer>
{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,
recordPickerComponentInstanceId,
);
const debouncedSetSearchFilter = useDebouncedCallback(
setRecordPickerSearchFilter,
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 { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
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 { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
@ -71,6 +72,8 @@ export const RecordDetailRelationSectionDropdownToMany = () => {
const { performSearch: multipleRecordPickerPerformSearch } =
useMultipleRecordPickerPerformSearch();
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
const handleCloseRelationPickerDropdown = useCallback(() => {
setMultipleRecordPickerSearchFilter('');
}, [setMultipleRecordPickerSearchFilter]);
@ -99,6 +102,8 @@ export const RecordDetailRelationSectionDropdownToMany = () => {
})),
);
openMultipleRecordPicker(dropdownId);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: dropdownId,
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 { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
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 { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
@ -87,8 +88,12 @@ export const RecordDetailRelationSectionDropdownToOne = () => {
recordId,
});
const { openSingleRecordPicker } = useSingleRecordPickerOpen();
const handleOpenRelationPickerDropdown = () => {
setSingleRecordPickerSearchFilter('');
openSingleRecordPicker(dropdownId);
if (relationRecords.length > 0) {
setSingleRecordPickerSelectedId(relationRecords[0]?.id);
}

View File

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

View File

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

View File

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