Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/styles/antd.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/features/group/AddGroupUser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })}
/>
</Form.Item>

Expand Down
2 changes: 2 additions & 0 deletions src/features/group/SelectGroup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
);
Expand Down
2 changes: 2 additions & 0 deletions src/features/group/UpdateGroup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</Form.Item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</Form.Item>
Expand Down
2 changes: 2 additions & 0 deletions src/features/transfer/MutateTransferForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</Form.Item>
Expand Down
1 change: 1 addition & 0 deletions src/shared/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './useModalState';
export * from './useDebouncedState';
32 changes: 32 additions & 0 deletions src/shared/hooks/useDebouncedState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { SetStateAction, useCallback, useEffect, useRef, useState } from 'react';

/**
* Hook for handling debounced state
*
* @param initialValue - initial state
* @param delay - state change timeout
*/
export function useDebouncedState<T>(initialValue: T, delay: number) {
const [value, setValue] = useState(initialValue);
const timeoutRef = useRef<number | null>(null);

const clearTimeout = () => window.clearTimeout(timeoutRef.current!);
useEffect(() => clearTimeout, []);

const setDebouncedValue = useCallback(
(newValue: SetStateAction<T>) => {
clearTimeout();
timeoutRef.current = window.setTimeout(() => {
setValue(newValue);
}, delay);
},
[delay],
);

const setValueImmediately = (newValue: T) => {
clearTimeout();
setValue(newValue);
};

return { value, setValue: setValueImmediately, setDebouncedValue };
}
6 changes: 5 additions & 1 deletion src/shared/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
76 changes: 52 additions & 24 deletions src/shared/ui/ManagedSelect/ManagedSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,75 @@
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 { PAGE_DEFAULT, PAGE_SIZE_DEFAULT } from './constants';
import { prepareOptionsForSelect } from './utils';
import { ManagedSelectProps } from './types';
import { useGetList, useGetSelectedItem, useHandleSelectEvents, usePrepareOptions, useSearch } from './hooks';

/** Select component for infinite pagination of data in a dropdown */
export const ManagedSelect = <T, V extends DefaultOptionType['value']>({
queryFunction,
queryKey,
detailQueryFunction,
detailQueryKey,
renderOptionValue,
renderOptionLabel,
value,
onBlur,
onSelect,
onSearch,
...props
}: ManagedSelectProps<T, V>) => {
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteRequest<T>({
const [hasTouched, setTouched] = useState(false);

const { searchValue, setSearchValue, handleSearch } = useSearch({ 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<HTMLDivElement> = (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 (
<Select
{...props}
options={options}
notFoundContent={isLoading ? <Spin size="small" /> : null}
// Do not transform value to option if there are not any options
value={options.length ? value : undefined}
showSearch
onDropdownVisibleChange={handleOpenDropdown}
onSelect={handleSelect}
onSearch={handleSearch}
filterOption={false}
// render notFoundContent when first request data is in progress
options={isLoading ? [] : options}
notFoundContent={isFetching ? <Spin size="small" /> : undefined}
onPopupScroll={handlePopupScroll}
onBlur={handleBlur}
{...props}
/>
);
};
5 changes: 5 additions & 0 deletions src/shared/ui/ManagedSelect/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export const PAGE_DEFAULT = 1;
export const PAGE_SIZE_DEFAULT = 50;
export const SEARCH_VALUE_DEFAULT = '';
// Debounced time for changing search value in Select
export const SEARCH_VALUE_CHANGE_DELAY = 500;
// Minimum delay to show loader when requesting 1st page of options
export const REQUEST_FIRST_PAGE_DELAY = 300;
5 changes: 5 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './useGetList';
export * from './useGetSelectedItem';
export * from './useSearch';
export * from './useHandleSelectEvents';
export * from './usePrepareOptions';
2 changes: 2 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/useGetList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './useGetList';
export * from './types';
18 changes: 18 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/useGetList/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { PaginationRequest, PaginationResponse } from '@shared/types';
import { QueryKey } from '@tanstack/react-query';

/**
* Interface as Props for hook "useGetList"
*
* @template T - Data object type for select options.
*/
export interface UseGetListProps<T> {
/** Select was in focus one time at least */
hasTouched: boolean;
/** Query keys for requests cache of entity list data */
queryKey: QueryKey;
/** Function for request entity list data */
queryFunction: (params: PaginationRequest) => Promise<PaginationResponse<T>>;
/** Search input value in select */
searchValue: string;
}
23 changes: 23 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/useGetList/useGetList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useInfiniteRequest } from '@shared/config';

import { PAGE_DEFAULT, PAGE_SIZE_DEFAULT, REQUEST_FIRST_PAGE_DELAY } from '../../constants';

import { UseGetListProps } from './types';

/** Hook for getting option list data for Select */
export const useGetList = <T>({ queryKey, queryFunction, searchValue, hasTouched }: UseGetListProps<T>) => {
return useInfiniteRequest<T>({
queryKey: [...queryKey, searchValue],
queryFn: async ({ pageParam }) => {
const response = await queryFunction({ ...pageParam, search_query: searchValue });
// Wait minimum delay to show loader when requesting 1st page
if (pageParam.page === PAGE_DEFAULT) {
await new Promise((resolve) => setTimeout(resolve, REQUEST_FIRST_PAGE_DELAY));
}
return response;
},
initialPageParam: { page: PAGE_DEFAULT, page_size: PAGE_SIZE_DEFAULT },
// Show first page of options when user touches select
enabled: hasTouched,
});
};
2 changes: 2 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/useGetSelectedItem/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './useGetSelectedItem';
export * from './types';
17 changes: 17 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/useGetSelectedItem/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { QueryKey } from '@tanstack/react-query';
import { DefaultOptionType } from 'antd/lib/select';

/**
* Interface as Props for hook "useGetSelectedItem"
*
* @template T - Data object type for select options
* @template V - Value type for select options
*/
export interface UseGetSelectedItemProps<T, V extends DefaultOptionType['value']> {
/** Query keys for requests cache of entity's detail data */
detailQueryKey: QueryKey;
/** Function for request detail entity data */
detailQueryFunction: (value: V) => Promise<T>;
/** Value of Select */
value: V;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { DefaultOptionType } from 'antd/lib/select';

import { UseGetSelectedItemProps } from './types';

/** Hook for getting selected option data for Select */
export const useGetSelectedItem = <T, V extends DefaultOptionType['value']>({
detailQueryKey,
detailQueryFunction,
value,
}: UseGetSelectedItemProps<T, V>) => {
return useQuery<T>({
queryKey: [...detailQueryKey, value],
queryFn: () => detailQueryFunction(value),
enabled: !!value,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './useHandleSelectEvents';
export * from './types';
17 changes: 17 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/useHandleSelectEvents/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { SelectProps } from 'antd';
import { DefaultOptionType } from 'antd/lib/select';

import { OptionItem } from '../../types';

/** Interface as Props for hook "useHandleSelectEvents" */
export interface UseHandleSelectEventsProps<T, V extends DefaultOptionType['value']>
extends Pick<SelectProps<V, OptionItem<T>>, 'onSelect' | 'onBlur'> {
/** Flag that shows if there is next page in infinite request of options in Select */
hasNextPage: boolean;
/** Callback for changing requesting next page of options in Select */
fetchNextPage: () => void;
/** Callback for changing search input value in Select */
setSearchValue: (value: string) => void;
/** Callback for changing touched state for Select */
setTouched: (value: boolean) => void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DefaultOptionType } from 'antd/lib/select';
import { FocusEventHandler, UIEventHandler } from 'react';
import { flushSync } from 'react-dom';

import { OptionItem } from '../../types';
import { SEARCH_VALUE_DEFAULT } from '../../constants';

import { UseHandleSelectEventsProps } from './types';

/** Hook for handling Select's component events */
export const useHandleSelectEvents = <T, V extends DefaultOptionType['value']>({
hasNextPage,
fetchNextPage,
setSearchValue,
setTouched,
onSelect = () => undefined,
onBlur = () => undefined,
}: UseHandleSelectEventsProps<T, V>) => {
const handleSelect = (newValue: V extends (infer A)[] ? A : V, option: OptionItem<T>) => {
setSearchValue(SEARCH_VALUE_DEFAULT);
onSelect(newValue, option);
};

const handleBlur: FocusEventHandler<HTMLElement> = (event) => {
setSearchValue(SEARCH_VALUE_DEFAULT);
onBlur(event);
};

const handlePopupScroll: UIEventHandler<HTMLDivElement> = (event) => {
const target = event.currentTarget;
if (hasNextPage && target.scrollTop + target.offsetHeight === target.scrollHeight) {
fetchNextPage();
}
};

const handleOpenDropdown = (open: boolean) => {
if (open) {
// Immediate change touched state to avoid show initial option in dropdown while first page options is loading
flushSync(() => {
setTouched(true);
});
}
};

return { handleSelect, handleBlur, handlePopupScroll, handleOpenDropdown };
};
2 changes: 2 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/usePrepareOptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './usePrepareOptions';
export * from './types';
16 changes: 16 additions & 0 deletions src/shared/ui/ManagedSelect/hooks/usePrepareOptions/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PaginationResponse } from '@shared/types';
import { DefaultOptionType } from 'antd/lib/select';

/** Interface as Props for hook "usePrepareOptions" */
export interface UsePrepareOptionsProps<T, V extends DefaultOptionType['value']> {
/** Search input value in Select */
searchValue: string;
/** Function render value for option from data object */
renderOptionValue: (item: T) => V;
/** Function render label for option from data object */
renderOptionLabel: (item: T) => DefaultOptionType['label'];
/** Response data from infinite request for options in Select */
dataList?: PaginationResponse<T>;
/** Selected option in Select */
selectedItem?: T;
}
Loading