feat: Unify the 'Add Model Provider' modal (#15768)

### What problem does this PR solve?

feat:Unify the 'Add Model Provider' modal

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
This commit is contained in:
chanx
2026-06-08 16:46:52 +08:00
committed by GitHub
parent 4bbd59823a
commit 144abbe2eb
40 changed files with 3706 additions and 3840 deletions

View File

@@ -1,17 +1,38 @@
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { isEmpty } from 'lodash';
import { X } from 'lucide-react';
import { ChevronDown, X } from 'lucide-react';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
/**
* Extracts text content from a ReactNode for filtering purposes.
* Handles strings, numbers, JSX elements with nested text, and arrays.
*/
const getNodeText = (node: React.ReactNode): string => {
if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}
if (React.isValidElement(node)) {
const children = (node.props as { children?: React.ReactNode }).children;
if (children) {
return getNodeText(children);
}
return '';
}
if (Array.isArray(node)) {
return node.map(getNodeText).join('');
}
return '';
};
/** Interface for tag select options */
export interface InputSelectOption {
/** Value of the option */
value: string;
/** Display label of the option */
label: string;
label: string | React.ReactNode;
}
/** Properties for the InputSelect component */
@@ -36,20 +57,72 @@ export interface InputSelectProps {
type?: 'text' | 'number' | 'date' | 'datetime';
}
/** Internal display for single-select selected value. Click label to re-edit (string labels only). */
const SingleSelectDisplay: React.FC<{
value: string | number | Date;
options: InputSelectOption[];
type: 'text' | 'number' | 'date' | 'datetime';
onEdit: (editText: string) => void;
onRemove: () => void;
}> = ({ value, options, type, onEdit, onRemove }) => {
const selectedOption = options.find((opt) =>
type === 'number'
? Number(opt.value) === Number(value)
: type === 'date' || type === 'datetime'
? new Date(opt.value).getTime() === new Date(value as any).getTime()
: String(opt.value) === String(value),
);
const label =
selectedOption?.label ??
(type === 'number'
? String(value)
: type === 'date' || type === 'datetime'
? new Date(value as any).toLocaleString()
: String(value));
const canEdit = typeof label === 'string';
return (
<div className={cn('flex items-center max-w-full')}>
<div
className={cn(
'flex-1 truncate',
canEdit ? 'cursor-text' : 'cursor-default',
)}
onClick={(e) => {
if (!canEdit) return;
e.stopPropagation();
onEdit(getNodeText(label));
}}
>
{label}
</div>
<button
type="button"
className="ml-2 flex-[0_0_24px] text-text-secondary hover:text-text-primary focus:outline-none"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
};
const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
(
{
options = [],
value = [],
onChange,
placeholder = 'Select tags...',
className,
style,
multi = false,
type = 'text',
},
ref,
) => {
({
options = [],
value = [],
onChange,
placeholder = 'Select tags...',
className,
style,
multi = false,
type = 'text',
}) => {
const [inputValue, setInputValue] = React.useState('');
const [open, setOpen] = React.useState(false);
const [isFocused, setIsFocused] = React.useState(false);
@@ -170,6 +243,50 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
setOpen(!!newValue); // Open popover when there's input
};
/**
* Commits the current inputValue to the selected values, matching by label first,
* then falling back to the typed value. No-op when inputValue is empty/whitespace.
* Used by Enter key handler and blur handler.
*/
const commitInputValue = () => {
if (inputValue.trim() === '') return;
// Match by label text first
const matchedOption = options.find(
(opt) =>
getNodeText(opt.label).toLowerCase() === inputValue.toLowerCase(),
);
if (matchedOption) {
handleAddTag(matchedOption.value);
return;
}
// Otherwise, validate by type and add as a new value
let valueToAdd: any;
if (type === 'number') {
const numValue = Number(inputValue);
if (isNaN(numValue)) return;
valueToAdd = numValue;
} else if (type === 'date' || type === 'datetime') {
const dateValue = new Date(inputValue);
if (isNaN(dateValue.getTime())) return;
valueToAdd = dateValue;
} else {
valueToAdd = inputValue;
}
// Skip if value is already selected
const isAlreadySelected = normalizedValue.some((v) =>
type === 'number'
? Number(v) === Number(valueToAdd)
: type === 'date' || type === 'datetime'
? new Date(v as any).getTime() === valueToAdd.getTime()
: String(v) === valueToAdd,
);
if (!isAlreadySelected) {
handleAddTag(valueToAdd);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (
e.key === 'Backspace' &&
@@ -196,43 +313,7 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
onChange?.(result);
} else if (e.key === 'Enter' && inputValue.trim() !== '') {
e.preventDefault();
let valueToAdd: any;
if (type === 'number') {
const numValue = Number(inputValue);
if (isNaN(numValue)) return; // Don't add invalid numbers
valueToAdd = numValue;
} else if (type === 'date' || type === 'datetime') {
const dateValue = new Date(inputValue);
if (isNaN(dateValue.getTime())) return; // Don't add invalid dates
valueToAdd = dateValue;
} else {
valueToAdd = inputValue;
}
// Add input value as a new tag if it doesn't exist in options
const matchedOption = options.find(
(opt) => opt.label.toLowerCase() === inputValue.toLowerCase(),
);
if (matchedOption) {
handleAddTag(matchedOption.value);
} else {
// If not in options, create a new tag with the input value
if (
!normalizedValue.some((v) =>
type === 'number'
? Number(v) === Number(valueToAdd)
: type === 'date' || type === 'datetime'
? new Date(v as any).getTime() === valueToAdd.getTime()
: String(v) === valueToAdd,
) &&
inputValue.trim() !== ''
) {
handleAddTag(valueToAdd);
}
}
commitInputValue();
} else if (e.key === 'Escape') {
inputRef.current?.blur();
setOpen(false);
@@ -254,8 +335,9 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
};
const handleInputBlur = () => {
// Delay closing to allow click on options
// Delay closing to allow click on options to register
setTimeout(() => {
commitInputValue();
setOpen(false);
setIsFocused(false);
}, 150);
@@ -279,7 +361,7 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
const filteredOptions = availableOptions.filter(
(option) =>
!inputValue ||
option.label
getNodeText(option.label)
.toLowerCase()
.includes(inputValue.toString().toLowerCase()),
);
@@ -290,7 +372,8 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
const hasLabelMatch = options.some(
(option) =>
option.label.toLowerCase() === inputValue.toString().toLowerCase(),
getNodeText(option.label).toLowerCase() ===
inputValue.toString().toLowerCase(),
);
let isAlreadySelected = false;
@@ -318,7 +401,7 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
const triggerElement = (
<div
className={cn(
'flex flex-wrap items-center gap-1 w-full rounded-md border-0.5 border-border-button bg-bg-input px-3 py-1 min-h-8 cursor-text',
'flex items-center gap-1 w-full rounded-md border-0.5 border-border-button bg-bg-input px-3 py-1 min-h-8 cursor-text',
'outline-none transition-colors',
'focus-within:outline-none focus-within:ring-1 focus-within:ring-accent-primary',
className,
@@ -326,109 +409,110 @@ const InputSelect = React.forwardRef<HTMLInputElement, InputSelectProps>(
style={style}
onClick={handleContainerClick}
>
{/* Render selected tags - only show tags if multi is true or if single select has a value */}
{multi &&
normalizedValue.map((tagValue, index) => {
const option = options.find((opt) =>
type === 'number'
? Number(opt.value) === Number(tagValue)
: type === 'date' || type === 'datetime'
? new Date(opt.value).getTime() ===
new Date(tagValue).getTime()
: String(opt.value) === String(tagValue),
) || {
value: String(tagValue),
label: String(tagValue),
};
return (
<div
key={`${tagValue}-${index}`}
className="flex items-center bg-bg-card text-text-primary rounded px-2 py-1 text-xs mr-1 mb-1 border border-border-card truncate"
>
<div className="flex-1 truncate">{option.label}</div>
<button
type="button"
className="ml-1 text-text-secondary hover:text-text-primary focus:outline-none"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(tagValue);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
{/* For single select, show the selected value as text instead of a tag */}
{!multi && !isEmpty(normalizedValue[0]) && (
<div className={cn('flex items-center max-w-full')}>
<div className="flex-1 truncate">
{options.find((opt) =>
{/* Wrapper for tags and input - this part wraps */}
<div className="flex flex-wrap items-center gap-1 flex-1 min-w-0">
{/* Render selected tags - only show tags if multi is true or if single select has a value */}
{multi &&
normalizedValue.map((tagValue, index) => {
const option = options.find((opt) =>
type === 'number'
? Number(opt.value) === Number(normalizedValue[0])
? Number(opt.value) === Number(tagValue)
: type === 'date' || type === 'datetime'
? new Date(opt.value).getTime() ===
new Date(normalizedValue[0]).getTime()
: String(opt.value) === String(normalizedValue[0]),
)?.label ||
(type === 'number'
? String(normalizedValue[0])
: type === 'date' || type === 'datetime'
? new Date(normalizedValue[0] as any).toLocaleString()
: String(normalizedValue[0]))}
</div>
<button
type="button"
className="ml-2 flex-[0_0_24px] text-text-secondary hover:text-text-primary focus:outline-none"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(normalizedValue[0]);
}}
>
<X className="h-3 w-3" />
</button>
</div>
)}
new Date(tagValue).getTime()
: String(opt.value) === String(tagValue),
) || {
value: String(tagValue),
label: String(tagValue),
};
{/* Input field for adding new tags - hide if single select and value is already selected, or in multi select when not focused */}
{(multi ? isFocused : multi || isEmpty(normalizedValue[0])) && (
<Input
ref={inputRef}
type={
type === 'date'
? 'date'
: type === 'datetime'
? 'datetime-local'
: type === 'number'
? 'number'
: 'text'
}
value={
type === 'number' && inputValue
? String(inputValue)
: type === 'date' || type === 'datetime'
? inputValue
: inputValue
}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
(
multi
? normalizedValue.length === 0
: isEmpty(normalizedValue[0])
)
? placeholder
: ''
}
className="flex-grow min-w-[50px] border-none px-1 py-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-auto "
onClick={(e) => e.stopPropagation()}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
return (
<div
key={`${tagValue}-${index}`}
className="flex items-center bg-bg-card text-text-primary rounded px-2 py-1 text-xs mr-1 mb-1 border border-border-card truncate"
>
<div className="flex-1 truncate">{option.label}</div>
<button
type="button"
className="ml-1 text-text-secondary hover:text-text-primary focus:outline-none"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(tagValue);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
{/* For single select, show the selected value as text instead of a tag */}
{!multi && !isEmpty(normalizedValue[0]) && (
<SingleSelectDisplay
value={normalizedValue[0]}
options={options}
type={type}
onEdit={(editText) => {
handleRemoveTag(normalizedValue[0]);
setInputValue(editText);
setIsFocused(true);
setOpen(true);
requestAnimationFrame(() => {
const input = inputRef.current;
if (input) {
input.focus();
input.setSelectionRange(editText.length, editText.length);
}
});
}}
onRemove={() => handleRemoveTag(normalizedValue[0])}
/>
)}
{/* Input field for adding new tags - hide if single select and value is already selected, or in multi select when not focused */}
{(multi ? isFocused : multi || isEmpty(normalizedValue[0])) && (
<Input
ref={inputRef}
type={
type === 'date'
? 'date'
: type === 'datetime'
? 'datetime-local'
: type === 'number'
? 'number'
: 'text'
}
value={
type === 'number' && inputValue
? String(inputValue)
: type === 'date' || type === 'datetime'
? inputValue
: inputValue
}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
(
multi
? normalizedValue.length === 0
: isEmpty(normalizedValue[0])
)
? placeholder
: ''
}
className="flex-grow min-w-[50px] border-none px-1 py-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-auto "
onClick={(e) => e.stopPropagation()}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-text-secondary shrink-0 transition-transform',
open && 'rotate-180',
)}
/>
</div>
);

View File

@@ -0,0 +1,308 @@
'use client';
import { cn } from '@/lib/utils';
import { ChevronsDown, Loader2, Search, X } from 'lucide-react';
import * as React from 'react';
export interface ToggleListOption<V = unknown> {
/** Arbitrary value, including objects. Used as the React key when primitive. */
value: V;
/** Display content. Strings participate in local search filtering. */
label: React.ReactNode;
/** Per-item click handler. Whether the list closes afterwards is up to the caller. */
onClick?: () => void;
}
export interface ToggleListLoadMore {
/** Whether more items are available to load. When false, the button is hidden. */
hasMore: boolean;
/** Triggered when the user clicks the "Load more" button at the bottom of the list. */
onLoadMore: () => void;
/** Show a loading spinner inside the button and disable interaction. */
loading?: boolean;
/** Custom label for the button. Defaults to "Load more". */
text?: React.ReactNode;
}
export interface ToggleListProps<V = unknown> {
/** Class applied to the outer container that wraps the button and the list. */
className?: string;
/** Text (or any node) shown inside the trigger button. */
btnText?: React.ReactNode;
/** List items rendered inside the scrollable area. */
options: ToggleListOption<V>[];
/**
* When true (default), clicking anywhere outside the component will close the list.
* Set to false if the list should only be toggled by the button itself.
*/
closeOnOutsideClick?: boolean;
/** Max height (in px) of the scrollable list area. Defaults to 500. */
maxHeight?: number;
/** Class applied to the list container (the box around the search input + items). */
listClassName?: string;
/** Class applied to each list item. */
itemClassName?: string;
/** Class applied to the trigger button (merged with the default styles). */
buttonClassName?: string;
/** Placeholder rendered when `options` is empty (and no query is active). */
emptyText?: React.ReactNode;
/** Placeholder rendered when a search query yields no matches. */
noResultsText?: React.ReactNode;
/** Show a search input above the list. */
searchable?: boolean;
/** Placeholder for the search input. */
searchPlaceholder?: string;
/**
* If provided, switches to API search mode: every query change calls this
* callback. The component does NOT filter `options` locally — the caller is
* expected to update `options` (typically after a debounce + fetch).
* If omitted, the component performs a case-insensitive substring filter
* locally against the stringified label.
*/
onSearch?: (query: string) => void;
/** Show a spinner inside the search input. Useful for API search loading state. */
searchLoading?: boolean;
/** Class applied to the search input wrapper. */
searchClassName?: string;
/**
* Load-more pagination config. The caller owns the data; the component just
* renders a button at the bottom of the list when `hasMore` is true or a
* load is in flight.
*/
loadMore?: ToggleListLoadMore;
/**
* Optional callback fired whenever the list is opened (`true`) or
* closed (`false`). The component still owns the open/close state; this
* is just a notification hook so callers can trigger side effects
* (e.g. lazy-fetching list data) on first open.
*/
onOpenChange?: (open: boolean) => void;
}
/**
* ToggleList — a button that toggles a vertically stacked, scrollable list area
* rendered directly below it (no portal/overlay). Click the button to expand,
* click again to collapse.
*
* Features:
* - The list occupies real DOM space and stretches to the parent's width
* (the trigger button keeps its natural width).
* - Optional search input. Local mode (no `onSearch`) filters options client-side;
* API mode (with `onSearch`) only emits the query and lets the caller refetch.
* - Optional load-more pagination. The caller owns the data; the component
* renders the trigger button.
*
* Behavior notes:
* - Clicking a list item calls that item's `onClick`. The component does not
* auto-close after a click — let the caller's onClick decide via controlled
* state if needed.
* - External-click closing is opt-out via `closeOnOutsideClick={false}`.
*/
export function ToggleList<V = unknown>({
className,
btnText,
options,
closeOnOutsideClick = true,
maxHeight = 500,
listClassName,
itemClassName,
buttonClassName,
emptyText = 'No options',
noResultsText = 'No matching results',
searchable = false,
searchPlaceholder = 'Search…',
onSearch,
searchLoading = false,
searchClassName,
loadMore,
onOpenChange,
}: ToggleListProps<V>) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const containerRef = React.useRef<HTMLDivElement>(null);
const listId = React.useId();
// Close on outside click
React.useEffect(() => {
if (!open || !closeOnOutsideClick) return;
const handlePointerDown = (event: MouseEvent) => {
const node = containerRef.current;
if (node && !node.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
};
}, [open, closeOnOutsideClick]);
const isApiSearch = Boolean(onSearch);
// Local search: case-insensitive substring filter on the stringified label.
// In API search mode the caller is responsible for filtering, so we pass through.
const filteredOptions = React.useMemo(() => {
if (isApiSearch || !query.trim()) return options;
const q = query.trim().toLowerCase();
return options.filter((opt) =>
String(opt.label ?? '')
.toLowerCase()
.includes(q),
);
}, [options, query, isApiSearch]);
const handleQueryChange = React.useCallback(
(next: string) => {
setQuery(next);
if (onSearch) onSearch(next);
},
[onSearch],
);
const showNoResults =
searchable && query.trim().length > 0 && filteredOptions.length === 0;
const showEmpty = !showNoResults && options.length === 0;
return (
<div ref={containerRef} className={cn('flex flex-col', className)}>
<button
type="button"
aria-expanded={open}
aria-controls={listId}
onClick={() =>
setOpen((prev) => {
const next = !prev;
onOpenChange?.(next);
return next;
})
}
className={cn(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded text-sm',
'h-8 px-3 bg-bg-card text-text-secondary border border-border-button self-start',
'hover:bg-border-button hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary/40',
buttonClassName,
)}
>
{btnText}
{typeof btnText === 'string' && (
<ChevronsDown
className={cn(
'size-4 shrink-0 transition-transform duration-200',
open && 'rotate-180',
)}
aria-hidden="true"
/>
)}
</button>
{open && (
<div
id={listId}
role="list"
className={cn(
'mt-1 flex flex-col rounded-md border border-border-button bg-bg-card w-full overflow-hidden',
listClassName,
)}
>
{searchable && (
<div
className={cn(
'flex items-center gap-2 px-2 h-9 border-b border-border-button',
searchClassName,
)}
>
<Search className="size-4 shrink-0 text-text-secondary" />
<input
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder={searchPlaceholder}
className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-text-secondary/60"
/>
{searchLoading && (
<Loader2 className="size-4 shrink-0 animate-spin text-text-secondary" />
)}
{query && !searchLoading && (
<button
type="button"
aria-label="Clear search"
onClick={() => handleQueryChange('')}
className="text-text-secondary hover:text-text-primary"
>
<X className="size-4" />
</button>
)}
</div>
)}
<div
className="flex-1 overflow-y-auto divide-y divide-border-button"
style={{ maxHeight }}
>
{showEmpty && (
<div className="px-3 py-2 text-sm text-text-secondary">
{emptyText}
</div>
)}
{showNoResults && (
<div className="px-3 py-2 text-sm text-text-secondary">
{noResultsText}
</div>
)}
{!showEmpty &&
!showNoResults &&
filteredOptions.map((option, index) => {
const raw = option.value;
// Use index as the key for objects (no stable identity) and
// for nullish values; stringify primitives for a stable key.
const key =
raw === null ||
raw === undefined ||
(typeof raw === 'object' && raw !== null)
? index
: String(raw);
return (
<div
key={key}
role="listitem"
onClick={() => {
option.onClick?.();
}}
className={cn(
'cursor-pointer px-3 py-2 text-sm hover:bg-border-button ',
itemClassName,
)}
>
{option.label}
</div>
);
})}
{loadMore &&
(loadMore.hasMore || loadMore.loading) &&
filteredOptions.length > 0 && (
<button
type="button"
disabled={!loadMore.hasMore || loadMore.loading}
onClick={loadMore.onLoadMore}
className={cn(
'w-full px-3 py-2 text-sm border-t border-border-button',
'text-text-secondary hover:text-text-primary hover:bg-border-button',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent',
)}
>
{loadMore.loading ? (
<span className="inline-flex items-center justify-center gap-2">
<Loader2 className="size-3.5 animate-spin" /> Loading
</span>
) : (
(loadMore.text ?? 'Load more')
)}
</button>
)}
</div>
</div>
)}
</div>
);
}