Skip to content

Commit

Permalink
fix(general-container-settings): debounce every 500ms for image searc…
Browse files Browse the repository at this point in the history
…hing (#1768)

* fix(general-container-settings): debounce every 500ms image searching

* fix: display creatable option
  • Loading branch information
RemiBonnet authored Nov 27, 2024
1 parent 51869a9 commit 5812356
Show file tree
Hide file tree
Showing 5 changed files with 53 additions and 36 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function useContainerImages({ organizationId, containerRegistryId, search
return useQuery({
...queries.organizations.containerImages({ organizationId, containerRegistryId, search }),
enabled,
keepPreviousData: true,
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function useContainerVersions({ organizationId, containerRegistryId, imag
versions: sortVersions(versions),
}))
},
refetchOnWindowFocus: false,
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({
Expand All @@ -15,6 +19,8 @@ jest.mock('@qovery/domains/organizations/feature', () => ({
versions: [],
},
],
isFetching: false,
refetch: () => Promise.resolve(),
}),
useContainerVersions: () => ({
data: [
Expand Down Expand Up @@ -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(<GeneralContainerSettings organization={mockOrganization} />)
)
// 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')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ export function ImageName({
const { data: containerRegistries = [] } = useContainerRegistries({ organizationId })
const watchImageName = useWatch({ control, name: 'image_name' }) || ''
const [customOptions, setCustomOptions] = useState<Value[]>([])
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 = [],
Expand All @@ -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
Expand All @@ -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 }) => ({
Expand All @@ -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 ? (
<Controller
name="image_name"
Expand All @@ -63,7 +76,7 @@ export function ImageName({
}}
render={({ field, fieldState: { error } }) => (
<InputSelect
onInputChange={(value) => 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)
Expand All @@ -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
/>
)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<MultiValue<Value> | SingleValue<Value>>([])
Expand Down Expand Up @@ -181,16 +185,9 @@ export function InputSelect({
)

const NoOptionsMessage = (props: NoticeProps<Value>) => {
if (value && value.length > minInputLength) {
return (
<components.NoOptionsMessage {...props}>
<div className="px-3 py-6 text-center">
<Icon iconName="wave-pulse" className="text-neutral-350" />
<p className="mt-1 text-xs font-medium text-neutral-350">No result for this search</p>
</div>
</components.NoOptionsMessage>
)
} else {
const value = props.selectProps.inputValue

if (value.length <= minInputLength) {
return (
<components.NoOptionsMessage {...props}>
<div className="px-3 py-1 text-center">
Expand All @@ -201,6 +198,15 @@ export function InputSelect({
</components.NoOptionsMessage>
)
}

return (
<components.NoOptionsMessage {...props}>
<div className="px-3 py-6 text-center">
<Icon iconName="wave-pulse" className="text-neutral-350" />
<p className="mt-1 text-xs font-medium text-neutral-350">No result for this search</p>
</div>
</components.NoOptionsMessage>
)
}

const LoadingMessage = (props: NoticeProps<Value>) => {
Expand All @@ -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

Expand Down Expand Up @@ -326,7 +319,7 @@ export function InputSelect({
data-testid="select-react-select"
{...selectProps}
isValidNewOption={isValidNewOption}
formatCreateLabel={(value) => `Select "${value}"`}
formatCreateLabel={formatCreateLabel ?? ((value) => `Select "${value}"`)}
/>
<input type="hidden" name={label} value={selectedValue} />
{!isFilter && (
Expand Down

0 comments on commit 5812356

Please sign in to comment.