From 5812356e6b0baa763e79a98eb517ef47f9811e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Bonnet?= Date: Wed, 27 Nov 2024 18:47:35 +0100 Subject: [PATCH] fix(general-container-settings): debounce every 500ms for image searching (#1768) * fix(general-container-settings): debounce every 500ms image searching * fix: display creatable option --- .../use-container-images.ts | 1 + .../use-container-versions.ts | 1 + .../ui/general-container-settings.spec.tsx | 11 ++++- .../ui/image-name.tsx | 35 +++++++++++----- .../inputs/input-select/input-select.tsx | 41 ++++++++----------- 5 files changed, 53 insertions(+), 36 deletions(-) diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-container-images/use-container-images.ts b/libs/domains/organizations/feature/src/lib/hooks/use-container-images/use-container-images.ts index 6d64a8e0e20..643d48fc757 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-container-images/use-container-images.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-container-images/use-container-images.ts @@ -12,6 +12,7 @@ export function useContainerImages({ organizationId, containerRegistryId, search return useQuery({ ...queries.organizations.containerImages({ organizationId, containerRegistryId, search }), enabled, + keepPreviousData: true, }) } diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-container-versions/use-container-versions.ts b/libs/domains/organizations/feature/src/lib/hooks/use-container-versions/use-container-versions.ts index 10142120af3..f0ec92e83bd 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-container-versions/use-container-versions.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-container-versions/use-container-versions.ts @@ -35,6 +35,7 @@ export function useContainerVersions({ organizationId, containerRegistryId, imag versions: sortVersions(versions), })) }, + refetchOnWindowFocus: false, }) } diff --git a/libs/shared/console-shared/src/lib/general-container-settings/ui/general-container-settings.spec.tsx b/libs/shared/console-shared/src/lib/general-container-settings/ui/general-container-settings.spec.tsx index 4be1c2c14e7..ec33038d179 100644 --- a/libs/shared/console-shared/src/lib/general-container-settings/ui/general-container-settings.spec.tsx +++ b/libs/shared/console-shared/src/lib/general-container-settings/ui/general-container-settings.spec.tsx @@ -6,6 +6,10 @@ import { GeneralContainerSettings } from './general-container-settings' const mockOrganization = organizationFactoryMock(1)[0] +jest.mock('@qovery/shared/util-hooks', () => ({ + useDebounce: () => 'my-custom-image', +})) + jest.mock('@qovery/domains/organizations/feature', () => ({ ...jest.requireActual('@qovery/domains/organizations/feature'), useContainerImages: () => ({ @@ -15,6 +19,8 @@ jest.mock('@qovery/domains/organizations/feature', () => ({ versions: [], }, ], + isFetching: false, + refetch: () => Promise.resolve(), }), useContainerVersions: () => ({ data: [ @@ -51,14 +57,15 @@ describe('CreateGeneralContainer', () => { }) it('should render inputs NOT available in the requests', async () => { - const { userEvent } = renderWithProviders( + const { debug, baseElement, userEvent } = renderWithProviders( wrapWithReactHookForm() ) // Registry await selectEvent.select(screen.getByLabelText('Registry'), ['my-registry']) + // Image name await userEvent.type(screen.getByLabelText('Image name'), 'my-custom-image') - await selectEvent.select(screen.getByLabelText('Image name'), ['Select "my-custom-image"']) + await selectEvent.select(screen.getByLabelText('Image name'), ['Select "my-custom-image" - not found in registry']) // Image tag await userEvent.type(screen.getByLabelText('Image tag'), '12.0.0') }) diff --git a/libs/shared/console-shared/src/lib/general-container-settings/ui/image-name.tsx b/libs/shared/console-shared/src/lib/general-container-settings/ui/image-name.tsx index 4167cc10bab..7522257a85c 100644 --- a/libs/shared/console-shared/src/lib/general-container-settings/ui/image-name.tsx +++ b/libs/shared/console-shared/src/lib/general-container-settings/ui/image-name.tsx @@ -22,8 +22,8 @@ export function ImageName({ const { data: containerRegistries = [] } = useContainerRegistries({ organizationId }) const watchImageName = useWatch({ control, name: 'image_name' }) || '' const [customOptions, setCustomOptions] = useState([]) - const [search, setSearch] = useState(watchImageName) - const debouncedImageName = useDebounce(search, DEBOUNCE_TIME) + const [searchParams, setSearchParams] = useState(watchImageName) + const debouncedImageName = useDebounce(searchParams, DEBOUNCE_TIME) const { data: containerImages = [], @@ -32,8 +32,8 @@ export function ImageName({ } = useContainerImages({ organizationId, containerRegistryId, - search: search || watchImageName, - enabled: (search || watchImageName).length > 2, + search: debouncedImageName || watchImageName, + enabled: (debouncedImageName || watchImageName).length > 2, }) // XXX: Available only for this kind of registry: https://qovery.atlassian.net/browse/FRT-1307?focusedCommentId=13219 @@ -43,8 +43,8 @@ export function ImageName({ // Refetch when debounced value changes useEffect(() => { - if (!isSearchFieldAvailable && debouncedImageName) refetchContainerImages() - }, [debouncedImageName, refetchContainerImages]) + if (!isSearchFieldAvailable && debouncedImageName.length > 2) refetchContainerImages() + }, [searchParams, refetchContainerImages]) const options = [ ...containerImages.map(({ image_name }) => ({ @@ -54,6 +54,19 @@ export function ImageName({ ...customOptions, ] + // Custom validation function for new options + const isValidNewOption = (inputValue: string) => { + // Check minimum length requirement + if (inputValue.length < 3) { + return false + } + + const normalizedInput = inputValue.toLowerCase().trim() + const valueExists = options.some((option) => option.value.toLowerCase() === normalizedInput) + + return !valueExists + } + return isSearchFieldAvailable ? ( ( setSearch(value)} + onInputChange={(value) => setSearchParams(value)} onChange={(value) => { // If the value doesn't exist in options, it's a new creation const existingOption = options.find((opt) => opt.value === value) @@ -83,11 +96,13 @@ export function ImageName({ options={options} error={error?.message} label="Image name" - filterOption="startsWith" minInputLength={3} - isSearchable - isLoading={isFetching} + isLoading={isFetching || searchParams !== debouncedImageName} + formatCreateLabel={(inputValue) => `Select "${inputValue}" - not found in registry`} + isValidNewOption={isValidNewOption} + filterOption="startsWith" isCreatable + isSearchable /> )} /> diff --git a/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx b/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx index 6c93416339f..6b89207d288 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-select/input-select.tsx @@ -47,6 +47,8 @@ export interface InputSelectProps { isCreatable?: boolean isLoading?: boolean minInputLength?: number + formatCreateLabel?: ((inputValue: string) => ReactNode) | undefined + isValidNewOption?: (inputValue: string) => boolean } export function InputSelect({ @@ -73,6 +75,8 @@ export function InputSelect({ filterOption = 'fuzzy', isCreatable = false, minInputLength = 0, + formatCreateLabel, + isValidNewOption, }: InputSelectProps) { const [focused, setFocused] = useState(false) const [selectedItems, setSelectedItems] = useState | SingleValue>([]) @@ -181,16 +185,9 @@ export function InputSelect({ ) const NoOptionsMessage = (props: NoticeProps) => { - if (value && value.length > minInputLength) { - return ( - -
- -

No result for this search

-
-
- ) - } else { + const value = props.selectProps.inputValue + + if (value.length <= minInputLength) { return (
@@ -201,6 +198,15 @@ export function InputSelect({ ) } + + return ( + +
+ +

No result for this search

+
+
+ ) } const LoadingMessage = (props: NoticeProps) => { @@ -213,19 +219,6 @@ export function InputSelect({ ) } - // Custom validation function for new options - const isValidNewOption = (inputValue: string) => { - // Check minimum length requirement - if (inputValue.length < minInputLength) { - return false - } - - const normalizedInput = inputValue.toLowerCase().trim() - const valueExists = options.some((option) => option.value.toLowerCase() === normalizedInput) - - return !valueExists - } - const currentIcon = options.find((option) => option.value === selectedValue) const hasIcon = !isMulti && currentIcon?.icon @@ -326,7 +319,7 @@ export function InputSelect({ data-testid="select-react-select" {...selectProps} isValidNewOption={isValidNewOption} - formatCreateLabel={(value) => `Select "${value}"`} + formatCreateLabel={formatCreateLabel ?? ((value) => `Select "${value}"`)} /> {!isFilter && (