mirror of https://github.com/buster-so/buster.git
update select
This commit is contained in:
parent
4fdcdc4573
commit
bc5b0e4f84
|
@ -162,6 +162,7 @@
|
|||
"jsdom": "^26.1.0",
|
||||
"msw-storybook-addon": "^2.0.5",
|
||||
"prettier-eslint": "^16.4.2",
|
||||
"storybook": "^8.6.14",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"msw": {
|
||||
|
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from '../collections/queryRequests';
|
||||
import type { RustApiError } from '../errors';
|
||||
import { prefetchGetMetricDataClient } from '../metrics/queryRequests';
|
||||
import { useGetUserFavorites } from '../users/queryRequestFavorites';
|
||||
import { useGetUserFavorites } from '../users/favorites';
|
||||
import {
|
||||
deleteChat,
|
||||
duplicateChat,
|
||||
|
|
|
@ -1,25 +1,13 @@
|
|||
import { QueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { userQueryKeys } from '@/api/query_keys/users';
|
||||
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
|
||||
import { getUserList, getUserList_server } from './requests';
|
||||
import { getUserToOrganization } from './requests';
|
||||
|
||||
export const useGetUserList = (params: Parameters<typeof getUserList>[0]) => {
|
||||
const queryFn = useMemoizedFn(() => getUserList(params));
|
||||
export const useGetUserToOrganization = (params: Parameters<typeof getUserToOrganization>[0]) => {
|
||||
const queryFn = useMemoizedFn(() => getUserToOrganization(params));
|
||||
|
||||
return useQuery({
|
||||
...userQueryKeys.userGetUserList(params),
|
||||
...userQueryKeys.userGetUserToOrganization(params),
|
||||
queryFn
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchGetUserList = async (
|
||||
params: Parameters<typeof getUserList>[0],
|
||||
queryClientProp?: QueryClient
|
||||
) => {
|
||||
const queryClient = queryClientProp || new QueryClient();
|
||||
await queryClient.prefetchQuery({
|
||||
...userQueryKeys.userGetUserList(params),
|
||||
queryFn: () => getUserList_server(params)
|
||||
});
|
||||
return queryClient;
|
||||
};
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
import { serverFetch } from '../../../createServerInstance';
|
||||
import { mainApi } from '../../instances';
|
||||
import type { UserListResponse } from '@buster/server-shared/user';
|
||||
import { mainApiV2 } from '../../instances';
|
||||
import {
|
||||
GetUserToOrganizationResponse,
|
||||
GetUserToOrganizationRequest
|
||||
} from '@buster/server-shared/user';
|
||||
|
||||
export const getUserList = async (payload: {
|
||||
team_id: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => {
|
||||
return mainApi
|
||||
.get<UserListResponse>('/users', { params: payload })
|
||||
export const getUserToOrganization = async (payload: GetUserToOrganizationRequest) => {
|
||||
return mainApiV2
|
||||
.get<GetUserToOrganizationResponse>('/users/organization', { params: payload })
|
||||
.then((response) => response.data);
|
||||
};
|
||||
|
||||
export const getUserList_server = async (payload: Parameters<typeof getUserList>[0]) => {
|
||||
return serverFetch<UserListResponse>('/users', { params: payload });
|
||||
};
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { userQueryKeys } from '@/api/query_keys/users';
|
||||
import { useUserConfigContextSelector } from '@/context/Users/BusterUserConfigProvider';
|
||||
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
|
||||
import {
|
||||
createUserFavorite,
|
||||
deleteUserFavorite,
|
||||
getUserFavorites,
|
||||
getUserFavorites_server,
|
||||
updateUserFavorites
|
||||
} from './requests';
|
||||
|
||||
export const useGetUserFavorites = () => {
|
||||
const queryFn = useMemoizedFn(async () => getUserFavorites());
|
||||
const organizationId = useUserConfigContextSelector((state) => state.userOrganizations?.id);
|
||||
return useQuery({
|
||||
...userQueryKeys.favoritesGetList,
|
||||
queryFn,
|
||||
enabled: !!organizationId
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchGetUserFavorites = async (queryClientProp?: QueryClient) => {
|
||||
const queryClient = queryClientProp || new QueryClient();
|
||||
await queryClient.prefetchQuery({
|
||||
...userQueryKeys.favoritesGetList,
|
||||
queryFn: () => getUserFavorites_server()
|
||||
});
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
export const useAddUserFavorite = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createUserFavorite,
|
||||
onMutate: (params) => {
|
||||
queryClient.setQueryData(userQueryKeys.favoritesGetList.queryKey, (prev) => {
|
||||
const prevIds = prev?.map((p) => p.id) || [];
|
||||
const dedupedAdd = params.filter((p) => !prevIds.includes(p.id));
|
||||
return [...dedupedAdd, ...(prev || [])];
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(userQueryKeys.favoritesGetList.queryKey, data);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteUserFavorite = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteUserFavorite,
|
||||
onMutate: (id) => {
|
||||
queryClient.setQueryData(userQueryKeys.favoritesGetList.queryKey, (prev) => {
|
||||
return prev?.filter((fav) => !id.includes(fav.id));
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(userQueryKeys.favoritesGetList.queryKey, data);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUserFavorites = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateUserFavorites,
|
||||
onMutate: (params) => {
|
||||
queryClient.setQueryData(userQueryKeys.favoritesGetList.queryKey, (prev) => {
|
||||
return prev?.filter((fav, index) => {
|
||||
const id = fav.id;
|
||||
const favorite = (prev || []).find((f) => f.id === id);
|
||||
if (!favorite) return false;
|
||||
return { ...favorite, index };
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
|
@ -77,6 +77,7 @@ export const useInviteUser = () => {
|
|||
onSuccess: () => {
|
||||
const user = queryClient.getQueryData(userQueryKeys.userGetUserMyself.queryKey);
|
||||
|
||||
// Invalidate organization users for all user's organizations
|
||||
for (const organization of user?.organizations || []) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [organizationQueryKeys.organizationUsers(organization.id).queryKey],
|
||||
|
@ -84,12 +85,11 @@ export const useInviteUser = () => {
|
|||
});
|
||||
}
|
||||
|
||||
for (const team of user?.teams || []) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [userQueryKeys.userGetUserList({ team_id: team.id }).queryKey],
|
||||
refetchType: 'all'
|
||||
});
|
||||
}
|
||||
// Invalidate all userGetUserToOrganization queries (any params)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: userQueryKeys.userGetUserToOrganization({}).queryKey.slice(0, -1),
|
||||
refetchType: 'all'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -8,9 +8,10 @@ import type {
|
|||
} from '@/api/asset_interfaces/users';
|
||||
import type { OrganizationUser } from '@buster/server-shared/organization';
|
||||
import type {
|
||||
GetUserToOrganizationRequest,
|
||||
GetUserToOrganizationResponse,
|
||||
UserFavoriteResponse,
|
||||
UserResponse,
|
||||
UserListResponse
|
||||
UserResponse
|
||||
} from '@buster/server-shared/user';
|
||||
|
||||
const favoritesGetList = queryOptions<UserFavoriteResponse>({
|
||||
|
@ -55,9 +56,9 @@ const userGetUserDatasetGroups = (userId: string) =>
|
|||
queryKey: ['users', userId, 'datasetGroups'] as const
|
||||
});
|
||||
|
||||
const userGetUserList = (params: { team_id: string; page?: number; page_size?: number }) =>
|
||||
queryOptions<UserListResponse>({
|
||||
queryKey: ['users', 'list', params] as const
|
||||
const userGetUserToOrganization = (params: GetUserToOrganizationRequest) =>
|
||||
queryOptions<GetUserToOrganizationResponse>({
|
||||
queryKey: ['users', 'organization', params] as const
|
||||
});
|
||||
|
||||
export const userQueryKeys = {
|
||||
|
@ -69,5 +70,5 @@ export const userQueryKeys = {
|
|||
userGetUserAttributes,
|
||||
userGetUserDatasets,
|
||||
userGetUserDatasetGroups,
|
||||
userGetUserList
|
||||
userGetUserToOrganization
|
||||
};
|
||||
|
|
|
@ -56,7 +56,7 @@ export interface DropdownProps<T = string> extends DropdownMenuProps {
|
|||
onSelect?: (value: T) => void;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
emptyStateText?: string;
|
||||
emptyStateText?: string | React.ReactNode;
|
||||
className?: string;
|
||||
footerContent?: React.ReactNode;
|
||||
showIndex?: boolean;
|
||||
|
|
|
@ -0,0 +1,206 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { useState } from 'react';
|
||||
import { InputSearchDropdown } from './InputSearchDropdown';
|
||||
|
||||
const meta: Meta<typeof InputSearchDropdown> = {
|
||||
title: 'UI/inputs/InputSearchDropdown',
|
||||
component: InputSearchDropdown,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placement: {
|
||||
control: { type: 'select' },
|
||||
options: ['top', 'bottom']
|
||||
},
|
||||
popoverMatchWidth: {
|
||||
control: { type: 'boolean' }
|
||||
},
|
||||
value: {
|
||||
control: { type: 'text' }
|
||||
},
|
||||
onSelect: {
|
||||
action: 'selected'
|
||||
},
|
||||
onSearch: {
|
||||
action: 'searched'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const sampleOptions = [
|
||||
{ label: 'Apple', value: 'apple' },
|
||||
{ label: 'Banana', value: 'banana' },
|
||||
{ label: 'Cherry', value: 'cherry' },
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Elderberry', value: 'elderberry' },
|
||||
{ label: 'Fig', value: 'fig' },
|
||||
{ label: 'Grape', value: 'grape' },
|
||||
{ label: 'Honeydew', value: 'honeydew' }
|
||||
];
|
||||
|
||||
// Interactive story with state management
|
||||
const InputSearchDropdownWithState = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '');
|
||||
const [filteredOptions, setFilteredOptions] = useState(sampleOptions);
|
||||
|
||||
const handleSearch = (searchValue: string) => {
|
||||
action('searched')(searchValue);
|
||||
const filtered = sampleOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
setFilteredOptions(filtered);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
action('selected')(selectedValue);
|
||||
const selectedOption = sampleOptions.find((option) => option.value === selectedValue);
|
||||
setValue(selectedOption?.label || selectedValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80">
|
||||
<InputSearchDropdown
|
||||
{...args}
|
||||
options={filteredOptions}
|
||||
value={value}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => <InputSearchDropdownWithState {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search fruits...',
|
||||
value: '',
|
||||
placement: 'bottom',
|
||||
popoverMatchWidth: true
|
||||
}
|
||||
};
|
||||
|
||||
export const WithInitialValue: Story = {
|
||||
render: (args) => <InputSearchDropdownWithState {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search fruits...',
|
||||
value: 'Apple',
|
||||
placement: 'bottom',
|
||||
popoverMatchWidth: true
|
||||
}
|
||||
};
|
||||
|
||||
export const TopPlacement: Story = {
|
||||
render: (args) => <InputSearchDropdownWithState {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search fruits...',
|
||||
value: '',
|
||||
placement: 'top',
|
||||
popoverMatchWidth: true
|
||||
}
|
||||
};
|
||||
|
||||
export const CustomEmptyState: Story = {
|
||||
render: (args) => <InputSearchDropdownWithState {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search fruits...',
|
||||
value: '',
|
||||
placement: 'bottom',
|
||||
popoverMatchWidth: true,
|
||||
emptyState: 'No fruits found matching your search'
|
||||
}
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
render: (args) => <InputSearchDropdownWithState {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search fruits...',
|
||||
value: '',
|
||||
placement: 'bottom',
|
||||
popoverMatchWidth: true,
|
||||
className: 'border-2 border-blue-500 rounded-lg',
|
||||
popoverClassName: 'bg-blue-50 border border-blue-200'
|
||||
}
|
||||
};
|
||||
|
||||
// Story with complex options (React nodes)
|
||||
const complexOptions = [
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full bg-red-500"></span>
|
||||
<span>Apple</span>
|
||||
<span className="text-sm text-gray-500">(Red fruit)</span>
|
||||
</div>
|
||||
),
|
||||
value: 'apple'
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full bg-yellow-500"></span>
|
||||
<span>Banana</span>
|
||||
<span className="text-sm text-gray-500">(Yellow fruit)</span>
|
||||
</div>
|
||||
),
|
||||
value: 'banana'
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full bg-red-600"></span>
|
||||
<span>Cherry</span>
|
||||
<span className="text-sm text-gray-500">(Small red fruit)</span>
|
||||
</div>
|
||||
),
|
||||
value: 'cherry'
|
||||
}
|
||||
];
|
||||
|
||||
const ComplexInputSearchDropdown = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '');
|
||||
const [filteredOptions, setFilteredOptions] = useState(complexOptions);
|
||||
|
||||
const handleSearch = (searchValue: string) => {
|
||||
action('searched')(searchValue);
|
||||
const filtered = complexOptions.filter((option) => {
|
||||
const labelText = typeof option.label === 'string' ? option.label : 'Apple Banana Cherry'; // Simplified for demo
|
||||
return labelText.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
setFilteredOptions(filtered);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
action('selected')(selectedValue);
|
||||
const selectedOption = complexOptions.find((option) => option.value === selectedValue);
|
||||
setValue(selectedValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80">
|
||||
<InputSearchDropdown
|
||||
{...args}
|
||||
options={filteredOptions}
|
||||
value={value}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplexOptions: Story = {
|
||||
render: (args) => <ComplexInputSearchDropdown {...args} />,
|
||||
args: {
|
||||
placeholder: 'Search fruits with icons...',
|
||||
value: '',
|
||||
placement: 'bottom',
|
||||
popoverMatchWidth: true
|
||||
}
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { InputSearchDropdown } from './InputSearchDropdown';
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('@/hooks', () => ({
|
||||
useMemoizedFn: (fn: any) => fn
|
||||
}));
|
||||
|
||||
// Mock the classMerge utility
|
||||
vi.mock('@/lib/classMerge', () => ({
|
||||
cn: (...classes: string[]) => classes.filter(Boolean).join(' ')
|
||||
}));
|
||||
|
||||
describe('InputSearchDropdown', () => {
|
||||
const mockOptions = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' }
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
options: mockOptions,
|
||||
onSelect: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
value: '',
|
||||
placeholder: 'Search...'
|
||||
};
|
||||
|
||||
it('renders input field with placeholder', () => {
|
||||
render(<InputSearchDropdown {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search...');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows dropdown when user starts typing', async () => {
|
||||
render(<InputSearchDropdown {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search...');
|
||||
fireEvent.change(input, { target: { value: 'test' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSearch).toHaveBeenCalledWith('test');
|
||||
});
|
||||
});
|
||||
|
||||
it('hides dropdown when input is empty', () => {
|
||||
render(<InputSearchDropdown {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search...');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
|
||||
// Dropdown should not be visible when input is empty
|
||||
expect(screen.queryByText('Option 1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelect when an option is clicked', async () => {
|
||||
render(<InputSearchDropdown {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search...');
|
||||
fireEvent.change(input, { target: { value: 'test' } });
|
||||
|
||||
// Wait for dropdown to appear and click an option
|
||||
await waitFor(() => {
|
||||
const option = screen.getByText('Option 1');
|
||||
fireEvent.click(option);
|
||||
});
|
||||
|
||||
expect(defaultProps.onSelect).toHaveBeenCalledWith('option1');
|
||||
});
|
||||
|
||||
it('updates input value when value prop changes', () => {
|
||||
const { rerender } = render(<InputSearchDropdown {...defaultProps} />);
|
||||
|
||||
rerender(<InputSearchDropdown {...defaultProps} value="new value" />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search...') as HTMLInputElement;
|
||||
expect(input.value).toBe('new value');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<InputSearchDropdown {...defaultProps} className="custom-class" />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search...');
|
||||
expect(input).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Select } from '../select/Select';
|
||||
|
||||
interface InputSearchDropdownProps {
|
||||
options: {
|
||||
label: string | React.ReactNode;
|
||||
value: string;
|
||||
}[];
|
||||
onSelect: (value: string) => void;
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
onSearch: (value: string) => void;
|
||||
value: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
matchPopUpWidth?: boolean;
|
||||
}
|
||||
|
||||
export const InputSearchDropdown = ({
|
||||
options,
|
||||
onSelect,
|
||||
placeholder = 'Search...',
|
||||
emptyMessage = 'No options found',
|
||||
matchPopUpWidth = true,
|
||||
onSearch,
|
||||
value,
|
||||
className,
|
||||
disabled = false
|
||||
}: InputSearchDropdownProps) => {
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
return (
|
||||
<Select
|
||||
items={options}
|
||||
placeholder={placeholder}
|
||||
onChange={onSelect}
|
||||
disabled={disabled}
|
||||
clearable={false}
|
||||
className={className}
|
||||
matchPopUpWidth={matchPopUpWidth}
|
||||
emptyMessage={emptyMessage}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
search={true}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -37,12 +37,13 @@ interface BaseSelectProps<T> {
|
|||
open?: boolean;
|
||||
showIndex?: boolean;
|
||||
className?: string;
|
||||
defaultValue?: string;
|
||||
dataTestId?: string;
|
||||
loading?: boolean;
|
||||
search?: boolean | SearchFunction<T>;
|
||||
emptyMessage?: string;
|
||||
matchPopUpWidth?: boolean;
|
||||
inputValue?: string;
|
||||
onInputValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
// Clearable version - onChange can return null
|
||||
|
@ -140,20 +141,25 @@ function SelectComponent<T = string>({
|
|||
open: controlledOpen,
|
||||
showIndex = false,
|
||||
className,
|
||||
defaultValue,
|
||||
dataTestId,
|
||||
loading = false,
|
||||
search = false,
|
||||
clearable = false,
|
||||
matchPopUpWidth = false
|
||||
matchPopUpWidth = false,
|
||||
inputValue,
|
||||
onInputValueChange
|
||||
}: SelectProps<T>) {
|
||||
const [internalOpen, setInternalOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
const [internalInputValue, setInternalInputValue] = React.useState('');
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const commandRef = React.useRef<HTMLDivElement>(null);
|
||||
const listboxId = React.useId();
|
||||
|
||||
// Use provided inputValue or internal state
|
||||
const currentInputValue = inputValue ?? internalInputValue;
|
||||
const setInputValue = onInputValueChange ?? setInternalInputValue;
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
|
@ -162,12 +168,13 @@ function SelectComponent<T = string>({
|
|||
setInternalOpen(newOpen);
|
||||
onOpenChange?.(newOpen);
|
||||
if (!newOpen) {
|
||||
setSearchValue('');
|
||||
// Clear search value when closing
|
||||
setInputValue('');
|
||||
setIsFocused(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onOpenChange]
|
||||
[disabled, onOpenChange, setInputValue]
|
||||
);
|
||||
|
||||
// Get all items in a flat array for easier processing
|
||||
|
@ -187,15 +194,15 @@ function SelectComponent<T = string>({
|
|||
// Filter items based on search
|
||||
const filterItem = React.useCallback(
|
||||
(item: SelectItem<T>): boolean => {
|
||||
if (!search || !searchValue) return true;
|
||||
if (!search || !currentInputValue) return true;
|
||||
|
||||
if (typeof search === 'function') {
|
||||
return search(item, searchValue);
|
||||
return search(item, currentInputValue);
|
||||
}
|
||||
|
||||
return defaultSearchFunction(item, searchValue);
|
||||
return defaultSearchFunction(item, currentInputValue);
|
||||
},
|
||||
[search, searchValue]
|
||||
[search, currentInputValue]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
|
@ -204,11 +211,11 @@ function SelectComponent<T = string>({
|
|||
if (item) {
|
||||
onChange(item.value);
|
||||
handleOpenChange(false);
|
||||
setSearchValue('');
|
||||
setInputValue('');
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[flatItems, onChange, handleOpenChange]
|
||||
[flatItems, onChange, handleOpenChange, setInputValue]
|
||||
);
|
||||
|
||||
const handleClear = React.useCallback(
|
||||
|
@ -218,23 +225,23 @@ function SelectComponent<T = string>({
|
|||
// Type assertion is safe here because handleClear is only called when clearable is true
|
||||
if (clearable) {
|
||||
(onChange as (value: T | null) => void)(null);
|
||||
setSearchValue('');
|
||||
setInputValue('');
|
||||
handleOpenChange(false);
|
||||
}
|
||||
},
|
||||
[onChange, handleOpenChange, clearable]
|
||||
[onChange, handleOpenChange, clearable, setInputValue]
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setSearchValue(newValue);
|
||||
setInputValue(newValue);
|
||||
|
||||
if (search !== false && newValue && !open) {
|
||||
handleOpenChange(true);
|
||||
}
|
||||
},
|
||||
[search, open, handleOpenChange]
|
||||
[search, open, handleOpenChange, setInputValue]
|
||||
);
|
||||
|
||||
const handleInputFocus = React.useCallback(() => {
|
||||
|
@ -286,7 +293,7 @@ function SelectComponent<T = string>({
|
|||
if (isGroupedItems(items)) {
|
||||
return items.map((group, groupIndex) => {
|
||||
const filteredItems = group.items.filter(filterItem);
|
||||
if (filteredItems.length === 0 && searchValue) return null;
|
||||
if (filteredItems.length === 0 && currentInputValue) return null;
|
||||
|
||||
return (
|
||||
<CommandGroup key={`${group.label}-${groupIndex}`} heading={group.label}>
|
||||
|
@ -316,18 +323,18 @@ function SelectComponent<T = string>({
|
|||
onSelect={handleSelect}
|
||||
/>
|
||||
));
|
||||
}, [items, flatItems, filterItem, searchValue, value, showIndex, handleSelect]);
|
||||
}, [items, flatItems, filterItem, currentInputValue, value, showIndex, handleSelect]);
|
||||
|
||||
// Display value in input when not focused/searching
|
||||
const inputDisplayValue = React.useMemo(() => {
|
||||
if (isFocused || searchValue) {
|
||||
return searchValue;
|
||||
if (isFocused || currentInputValue) {
|
||||
return currentInputValue;
|
||||
}
|
||||
if (selectedItem) {
|
||||
return typeof selectedItem.label === 'string' ? selectedItem.label : '';
|
||||
}
|
||||
return '';
|
||||
}, [isFocused, searchValue, selectedItem]);
|
||||
}, [isFocused, currentInputValue, selectedItem]);
|
||||
|
||||
// Compute placeholder once
|
||||
const computedPlaceholder = React.useMemo(() => {
|
||||
|
@ -358,7 +365,7 @@ function SelectComponent<T = string>({
|
|||
'bg-background cursor-pointer transition-all duration-300',
|
||||
'focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
disabled ? 'bg-disabled text-gray-light' : '',
|
||||
!selectedItem && !searchValue && 'text-text-secondary'
|
||||
!selectedItem && !currentInputValue && 'text-text-secondary'
|
||||
)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
|
@ -394,8 +401,8 @@ function SelectComponent<T = string>({
|
|||
<Command ref={commandRef} shouldFilter={false}>
|
||||
{/* Hidden input that Command uses for keyboard navigation */}
|
||||
<CommandInput
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
value={currentInputValue}
|
||||
onValueChange={setInputValue}
|
||||
parentClassName="sr-only hidden h-0 border-0 p-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
|
|
@ -51,14 +51,14 @@ export const GetUserListRequestSchema = z.object({
|
|||
export type GetUserListRequest = z.infer<typeof GetUserListRequestSchema>;
|
||||
|
||||
export const GetUserToOrganizationRequestSchema = z.object({
|
||||
page: z.coerce.number().min(1).optional().default(1),
|
||||
page_size: z.coerce.number().min(1).max(5000).optional().default(25),
|
||||
page: z.coerce.number().min(1).default(1).optional(),
|
||||
page_size: z.coerce.number().min(1).max(5000).default(25).optional(),
|
||||
user_name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
//We need this because the frontend sends the roles as a comma-separated string in the query params
|
||||
role: createOptionalQueryArrayPreprocessor(OrganizationRoleSchema),
|
||||
role: createOptionalQueryArrayPreprocessor(OrganizationRoleSchema).optional(),
|
||||
//We need this because the frontend sends the status as a comma-separated string in the query params
|
||||
status: createOptionalQueryArrayPreprocessor(OrganizationStatusSchema),
|
||||
status: createOptionalQueryArrayPreprocessor(OrganizationStatusSchema).optional(),
|
||||
});
|
||||
|
||||
export type GetUserToOrganizationRequest = z.infer<typeof GetUserToOrganizationRequestSchema>;
|
||||
|
|
|
@ -17,20 +17,10 @@ export const UserResponseSchema = z.object({
|
|||
organizations: z.array(OrganizationWithUserRoleSchema).nullable(),
|
||||
});
|
||||
|
||||
export const UserListResponseSchema = z.array(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
role: OrganizationRoleSchema.nullable(),
|
||||
})
|
||||
);
|
||||
|
||||
export const UserFavoriteResponseSchema = z.array(UserFavoriteSchema);
|
||||
|
||||
export const GetUserToOrganizationResponseSchema = PaginatedResponseSchema(OrganizationUserSchema);
|
||||
|
||||
export type UserResponse = z.infer<typeof UserResponseSchema>;
|
||||
export type UserListResponse = z.infer<typeof UserListResponseSchema>;
|
||||
export type UserFavoriteResponse = z.infer<typeof UserFavoriteResponseSchema>;
|
||||
export type GetUserToOrganizationResponse = z.infer<typeof GetUserToOrganizationResponseSchema>;
|
||||
|
|
|
@ -614,6 +614,9 @@ importers:
|
|||
prettier-eslint:
|
||||
specifier: ^16.4.2
|
||||
version: 16.4.2(typescript@5.8.3)
|
||||
storybook:
|
||||
specifier: ^8.6.14
|
||||
version: 8.6.14(prettier@3.6.2)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.0.10)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.10.4(@types/node@24.0.10)(typescript@5.8.3))(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
|
||||
|
@ -5947,7 +5950,6 @@ packages:
|
|||
|
||||
bun@1.2.18:
|
||||
resolution: {integrity: sha512-OR+EpNckoJN4tHMVZPaTPxDj2RgpJgJwLruTIFYbO3bQMguLd0YrmkWKYqsiihcLgm2ehIjF/H1RLfZiRa7+qQ==}
|
||||
cpu: [arm64, x64, aarch64]
|
||||
os: [darwin, linux, win32]
|
||||
hasBin: true
|
||||
|
||||
|
@ -17069,14 +17071,14 @@ snapshots:
|
|||
msw: 2.10.4(@types/node@20.19.4)(typescript@5.8.3)
|
||||
vite: 6.3.5(@types/node@20.19.4)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
|
||||
|
||||
'@vitest/mocker@3.2.4(msw@2.10.4(@types/node@24.0.10)(typescript@5.8.3))(vite@6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))':
|
||||
'@vitest/mocker@3.2.4(msw@2.10.4(@types/node@24.0.10)(typescript@5.8.3))(vite@6.3.5(@types/node@20.19.4)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.17
|
||||
optionalDependencies:
|
||||
msw: 2.10.4(@types/node@24.0.10)(typescript@5.8.3)
|
||||
vite: 6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
|
||||
vite: 6.3.5(@types/node@20.19.4)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
|
||||
|
||||
'@vitest/pretty-format@2.0.5':
|
||||
dependencies:
|
||||
|
@ -23634,7 +23636,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/chai': 5.2.2
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@24.0.10)(typescript@5.8.3))(vite@6.3.5(@types/node@24.0.10)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
|
||||
'@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@24.0.10)(typescript@5.8.3))(vite@6.3.5(@types/node@20.19.4)(jiti@2.4.2)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
|
|
Loading…
Reference in New Issue