diff --git a/src/app/styles/antd.less b/src/app/styles/antd.less index beb103a7..17bc83e0 100644 --- a/src/app/styles/antd.less +++ b/src/app/styles/antd.less @@ -27,7 +27,8 @@ } .ant-avatar, -.ant-modal-close-x { +.ant-modal-close-x, +.ant-select-item-empty { display: flex; justify-content: center; align-items: center; diff --git a/src/features/group/AddGroupUser/index.tsx b/src/features/group/AddGroupUser/index.tsx index a9e2de9e..24429c72 100644 --- a/src/features/group/AddGroupUser/index.tsx +++ b/src/features/group/AddGroupUser/index.tsx @@ -23,8 +23,10 @@ export const AddGroupUser = ({ groupId, onSuccess, onCancel }: AddGroupUserProps size="large" queryKey={[UserQueryKey.GET_USERS]} queryFunction={userService.getUsers} - renderOptionValue={(user) => user.id} renderOptionLabel={(user) => user.username} + renderOptionValue={(user) => user.id} + detailQueryKey={[UserQueryKey.GET_USER]} + detailQueryFunction={(value) => userService.getUser({ id: value })} /> diff --git a/src/features/group/SelectGroup/index.tsx b/src/features/group/SelectGroup/index.tsx index 7295eb30..514da641 100644 --- a/src/features/group/SelectGroup/index.tsx +++ b/src/features/group/SelectGroup/index.tsx @@ -17,6 +17,8 @@ export const SelectGroup = () => { onSelect={handleSelectGroup} renderOptionLabel={(group) => group.data.name} renderOptionValue={(group) => group.data.id} + detailQueryKey={[GroupQueryKey.GET_GROUP]} + detailQueryFunction={(value) => groupService.getGroup({ id: value })} placeholder="Select group" /> ); diff --git a/src/features/group/UpdateGroup/index.tsx b/src/features/group/UpdateGroup/index.tsx index 7dfb9f56..a1847a54 100644 --- a/src/features/group/UpdateGroup/index.tsx +++ b/src/features/group/UpdateGroup/index.tsx @@ -49,6 +49,8 @@ export const UpdateGroup = ({ group }: UpdateGroupProps) => { queryFunction={userService.getUsers} renderOptionValue={(user) => user.id} renderOptionLabel={(user) => user.username} + detailQueryKey={[UserQueryKey.GET_USER]} + detailQueryFunction={(value) => userService.getUser({ id: value })} //TODO: [DOP-20030] Need to delete prop "disabled" when the backend leaves the user with access to the group, even after changing the owner disabled /> diff --git a/src/features/transfer/MutateTransferForm/components/SourceParams/SourceParams.tsx b/src/features/transfer/MutateTransferForm/components/SourceParams/SourceParams.tsx index 60e5f389..8fb9a848 100644 --- a/src/features/transfer/MutateTransferForm/components/SourceParams/SourceParams.tsx +++ b/src/features/transfer/MutateTransferForm/components/SourceParams/SourceParams.tsx @@ -25,6 +25,8 @@ export const SourceParams = ({ groupId, initialSourceConnectionType }: SourcePar renderOptionValue={(connection) => connection.id} renderOptionLabel={(connection) => connection.name} onSelect={handleSelectConnection} + detailQueryKey={[ConnectionQueryKey.GET_CONNECTION]} + detailQueryFunction={(value) => connectionService.getConnection({ id: value })} placeholder="Select source connection" /> diff --git a/src/features/transfer/MutateTransferForm/components/TargetParams/TargetParams.tsx b/src/features/transfer/MutateTransferForm/components/TargetParams/TargetParams.tsx index 4f841d5d..71da7d01 100644 --- a/src/features/transfer/MutateTransferForm/components/TargetParams/TargetParams.tsx +++ b/src/features/transfer/MutateTransferForm/components/TargetParams/TargetParams.tsx @@ -25,6 +25,8 @@ export const TargetParams = ({ groupId, initialTargetConnectionType }: TargetPar renderOptionValue={(connection) => connection.id} renderOptionLabel={(connection) => connection.name} onSelect={handleSelectConnection} + detailQueryKey={[ConnectionQueryKey.GET_CONNECTION]} + detailQueryFunction={(value) => connectionService.getConnection({ id: value })} placeholder="Select target connection" /> diff --git a/src/features/transfer/MutateTransferForm/index.tsx b/src/features/transfer/MutateTransferForm/index.tsx index 80617bf6..15599be3 100644 --- a/src/features/transfer/MutateTransferForm/index.tsx +++ b/src/features/transfer/MutateTransferForm/index.tsx @@ -30,6 +30,8 @@ export const MutateTransferForm = ({ queryFunction={(params) => queueService.getQueues({ group_id: group.id, ...params })} renderOptionValue={(queue) => queue.id} renderOptionLabel={(queue) => queue.name} + detailQueryKey={[QueueQueryKey.GET_QUEUE]} + detailQueryFunction={(value) => queueService.getQueue({ id: value })} placeholder="Select queue" /> diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index a0337922..0550f940 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1 +1,2 @@ export * from './useModalState'; +export * from './useDebouncedState'; diff --git a/src/shared/hooks/useDebouncedState/index.ts b/src/shared/hooks/useDebouncedState/index.ts new file mode 100644 index 00000000..079040b1 --- /dev/null +++ b/src/shared/hooks/useDebouncedState/index.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { UseDebouncedStateProps } from './types'; + +/** Hook for handling debounced state */ +export function useDebouncedState({ initialValue, delay, onDebounce = () => undefined }: UseDebouncedStateProps) { + const [value, setValue] = useState(initialValue); + const timeoutRef = useRef(null); + + const clearTimeout = () => window.clearTimeout(timeoutRef.current!); + useEffect(() => clearTimeout, []); + + const setDebouncedValue = useCallback( + (newValue: T) => { + clearTimeout(); + timeoutRef.current = window.setTimeout(() => { + setValue(newValue); + onDebounce(newValue); + }, delay); + }, + [delay, onDebounce], + ); + + const setValueImmediately = (newValue: T) => { + clearTimeout(); + setValue(newValue); + onDebounce(newValue); + }; + + return { value, setValue: setValueImmediately, setDebouncedValue }; +} diff --git a/src/shared/hooks/useDebouncedState/types.ts b/src/shared/hooks/useDebouncedState/types.ts new file mode 100644 index 00000000..28653cb8 --- /dev/null +++ b/src/shared/hooks/useDebouncedState/types.ts @@ -0,0 +1,13 @@ +/** + * Interface as Props type of "useDebouncedState" hook. + * + * @template T - Value type. + */ +export interface UseDebouncedStateProps { + /** Initial value */ + initialValue: T; + /** Debounce timeout */ + delay: number; + /** Callback for handling debounced state change */ + onDebounce?: (value: T) => void; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 20af25fa..e635120b 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -19,4 +19,8 @@ export interface PageParams { page: number; } -export interface PaginationRequest extends PageParams {} +export interface SearchParams { + search_query?: string; +} + +export interface PaginationRequest extends PageParams, SearchParams {} diff --git a/src/shared/ui/ManagedSelect/ManagedSelect.tsx b/src/shared/ui/ManagedSelect/ManagedSelect.tsx index 91b88951..cd966049 100644 --- a/src/shared/ui/ManagedSelect/ManagedSelect.tsx +++ b/src/shared/ui/ManagedSelect/ManagedSelect.tsx @@ -1,47 +1,81 @@ -import React, { UIEventHandler, useMemo } from 'react'; +import React, { useState } from 'react'; import { Select, Spin } from 'antd'; -import { useInfiniteRequest } from '@shared/config'; import { DefaultOptionType } from 'antd/lib/select'; +import { useDebouncedState } from '@shared/hooks'; -import { PAGE_DEFAULT, PAGE_SIZE_DEFAULT } from './constants'; -import { prepareOptionsForSelect } from './utils'; import { ManagedSelectProps } from './types'; +import { useGetList, useGetSelectedItem, useHandleSelectEvents, usePrepareOptions } from './hooks'; +import { SEARCH_VALUE_CHANGE_DELAY, SEARCH_VALUE_DEFAULT } from './constants'; /** Select component for infinite pagination of data in a dropdown */ export const ManagedSelect = ({ queryFunction, queryKey, + detailQueryFunction, + detailQueryKey, renderOptionValue, renderOptionLabel, + value, + onBlur, + onSelect, + onSearch, ...props }: ManagedSelectProps) => { - const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteRequest({ + const [hasTouched, setTouched] = useState(false); + + const { + value: searchValue, + setValue: setSearchValue, + setDebouncedValue: handleDebouncedSearchValue, + } = useDebouncedState({ initialValue: SEARCH_VALUE_DEFAULT, delay: SEARCH_VALUE_CHANGE_DELAY, onDebounce: onSearch }); + + const { data, hasNextPage, fetchNextPage, isLoading, isFetching } = useGetList({ queryKey, - queryFn: ({ pageParam }) => queryFunction(pageParam), - initialPageParam: { page: PAGE_DEFAULT, page_size: PAGE_SIZE_DEFAULT }, + queryFunction, + hasTouched, + searchValue, }); - const options = useMemo(() => { - return prepareOptionsForSelect({ - data: data?.items, - renderValue: renderOptionValue, - renderLabel: renderOptionLabel, - }); - }, [data, renderOptionValue, renderOptionLabel]); - - const handlePopupScroll: UIEventHandler = (event) => { - const target = event.currentTarget; - if (hasNextPage && target.scrollTop + target.offsetHeight === target.scrollHeight) { - fetchNextPage(); - } - }; + const { data: selectedItem } = useGetSelectedItem({ + detailQueryKey, + detailQueryFunction, + // 'as' is needed to pass an existing initial value to the useGetSelectedItem. + // It will always be of type V, since useGetSelectedItem checks for this + value: value as V, + }); + + const { handleSelect, handleBlur, handleOpenDropdown, handlePopupScroll } = useHandleSelectEvents({ + onBlur, + onSelect, + setTouched, + setSearchValue, + hasNextPage, + fetchNextPage, + }); + + const options = usePrepareOptions({ + dataList: data, + searchValue, + selectedItem, + renderOptionValue, + renderOptionLabel, + }); return (