refactor(web): redesign Select component and migrate WorkplaceSelector (#35293)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-04-16 12:53:21 +08:00 committed by GitHub
parent 883d757392
commit b08665e598
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 447 additions and 441 deletions

View File

@ -44,18 +44,25 @@ vi.mock('@/app/components/base/select', () => ({
),
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
<div>
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <span>select-value</span>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/select')>()
return {
...actual,
Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => (
<div>
<button type="button" onClick={() => onValueChange(value === 'true' ? 'false' : 'beta')}>{`ui-select:${value}`}</button>
{children}
</div>
),
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <span>select-value</span>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span data-testid="select-item-indicator" />,
}
})
vi.mock('../field', () => ({
default: ({ children, title }: { children: ReactNode, title: string }) => (

View File

@ -12,6 +12,8 @@ import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
SelectValue,
} from '@/app/components/base/ui/select'
@ -138,8 +140,14 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
<SelectItem value={CHECKBOX_DEFAULT_TRUE_VALUE}>{t('variableConfig.startChecked', { ns: 'appDebug' })}</SelectItem>
<SelectItem value={CHECKBOX_DEFAULT_FALSE_VALUE}>{t('variableConfig.noDefaultSelected', { ns: 'appDebug' })}</SelectItem>
<SelectItem value={CHECKBOX_DEFAULT_TRUE_VALUE}>
<SelectItemText>{t('variableConfig.startChecked', { ns: 'appDebug' })}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value={CHECKBOX_DEFAULT_FALSE_VALUE}>
<SelectItemText>{t('variableConfig.noDefaultSelected', { ns: 'appDebug' })}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</Field>
@ -161,9 +169,15 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
<SelectItem value={EMPTY_SELECT_VALUE}>{t('variableConfig.noDefaultValue', { ns: 'appDebug' })}</SelectItem>
<SelectItem value={EMPTY_SELECT_VALUE}>
<SelectItemText>{t('variableConfig.noDefaultValue', { ns: 'appDebug' })}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
{options.filter(option => option.trim() !== '').map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>

View File

@ -11,7 +11,7 @@ import FormGeneration from '@/app/components/base/features/new-feature-panel/mod
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import { Button } from '@/app/components/base/ui/button'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { toast } from '@/app/components/base/ui/toast'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { useDocLink, useLocale } from '@/context/i18n'
@ -129,7 +129,8 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
<SelectContent popupClassName="w-[354px]">
{providers.map(option => (
<SelectItem key={option.key} value={option.key}>
{option.name}
<SelectItemText>{option.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>

View File

@ -10,7 +10,7 @@ import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-tim
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { Button } from '@/app/components/base/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
enum DATA_FORMAT {
TEXT = 'text',
@ -316,7 +316,10 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
</SelectTrigger>
<SelectContent>
{options.map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>

View File

@ -5,14 +5,14 @@ import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import {
menuBackdropClassName,
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
overlayBackdropClassName,
overlayGroupLabelClassName,
overlayIndicatorClassName,
overlayPopupAnimationClassName,
overlayPopupBaseClassName,
overlayRowClassName,
overlaySeparatorClassName,
} from '@/app/components/base/ui/overlay-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
export const ContextMenu = BaseContextMenu.Root
@ -65,7 +65,7 @@ function renderContextMenuPopup({
return (
<BaseContextMenu.Portal>
{withBackdrop && (
<BaseContextMenu.Backdrop className={menuBackdropClassName} />
<BaseContextMenu.Backdrop className={overlayBackdropClassName} />
)}
<BaseContextMenu.Positioner
side={side}
@ -77,8 +77,8 @@ function renderContextMenuPopup({
>
<BaseContextMenu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
overlayPopupBaseClassName,
overlayPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
@ -124,7 +124,7 @@ export function ContextMenuItem({
}: ContextMenuItemProps) {
return (
<BaseContextMenu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
@ -142,7 +142,7 @@ export function ContextMenuLinkItem({
}: ContextMenuLinkItemProps) {
return (
<BaseContextMenu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
@ -155,7 +155,7 @@ export function ContextMenuRadioItem({
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>) {
return (
<BaseContextMenu.RadioItem
className={cn(menuRowClassName, className)}
className={cn(overlayRowClassName, className)}
{...props}
/>
)
@ -167,7 +167,7 @@ export function ContextMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>) {
return (
<BaseContextMenu.CheckboxItem
className={cn(menuRowClassName, className)}
className={cn(overlayRowClassName, className)}
{...props}
/>
)
@ -179,7 +179,7 @@ export function ContextMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItemIndicator>, 'children'>) {
return (
<BaseContextMenu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(overlayIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -193,7 +193,7 @@ export function ContextMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItemIndicator>, 'children'>) {
return (
<BaseContextMenu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(overlayIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -213,7 +213,7 @@ export function ContextMenuSubTrigger({
}: ContextMenuSubTriggerProps) {
return (
<BaseContextMenu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
@ -261,7 +261,7 @@ export function ContextMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>) {
return (
<BaseContextMenu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
className={cn(overlayGroupLabelClassName, className)}
{...props}
/>
)
@ -273,7 +273,7 @@ export function ContextMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>) {
return (
<BaseContextMenu.Separator
className={cn(menuSeparatorClassName, className)}
className={cn(overlaySeparatorClassName, className)}
{...props}
/>
)

View File

@ -5,13 +5,13 @@ import { Menu } from '@base-ui/react/menu'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import {
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
overlayGroupLabelClassName,
overlayIndicatorClassName,
overlayPopupAnimationClassName,
overlayPopupBaseClassName,
overlayRowClassName,
overlaySeparatorClassName,
} from '@/app/components/base/ui/overlay-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
export const DropdownMenu = Menu.Root
@ -26,7 +26,7 @@ export function DropdownMenuRadioItem({
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(menuRowClassName, className)}
className={cn(overlayRowClassName, className)}
{...props}
/>
)
@ -38,7 +38,7 @@ export function DropdownMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(overlayIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -52,7 +52,7 @@ export function DropdownMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(menuRowClassName, className)}
className={cn(overlayRowClassName, className)}
{...props}
/>
)
@ -64,7 +64,7 @@ export function DropdownMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
className={cn(overlayIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -78,7 +78,7 @@ export function DropdownMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
className={cn(overlayGroupLabelClassName, className)}
{...props}
/>
)
@ -135,8 +135,8 @@ function renderDropdownMenuPopup({
>
<Menu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
overlayPopupBaseClassName,
overlayPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
@ -182,7 +182,7 @@ export function DropdownMenuSubTrigger({
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
@ -235,7 +235,7 @@ export function DropdownMenuItem({
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
@ -253,7 +253,7 @@ export function DropdownMenuLinkItem({
}: DropdownMenuLinkItemProps) {
return (
<Menu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
className={cn(overlayRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
@ -266,7 +266,7 @@ export function DropdownMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn(menuSeparatorClassName, className)}
className={cn(overlaySeparatorClassName, className)}
{...props}
/>
)

View File

@ -1,7 +0,0 @@
export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-hidden data-highlighted:bg-state-base-hover data-disabled:cursor-not-allowed data-disabled:opacity-30'
export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const menuPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]'
export const menuPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'
export const menuBackdropClassName = 'fixed inset-0 z-1002 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'

View File

@ -0,0 +1,7 @@
export const overlayRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-hidden data-highlighted:bg-state-base-hover data-disabled:cursor-not-allowed data-disabled:opacity-30'
export const overlayIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
export const overlayGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
export const overlaySeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const overlayPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]'
export const overlayPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'
export const overlayBackdropClassName = 'fixed inset-0 z-1002 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none'

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../index'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '../index'
const renderOpenSelect = ({
rootProps = {},
@ -33,8 +33,14 @@ const renderOpenSelect = ({
}}
{...contentProps}
>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="new-york">New York</SelectItem>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>,
)
@ -50,8 +56,14 @@ describe('Select wrappers', () => {
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="new-york">New York</SelectItem>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york">
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>
</form>,
@ -66,22 +78,6 @@ describe('Select wrappers', () => {
})
describe('SelectTrigger', () => {
it('should render clear button when clearable is true and loading is false', () => {
renderOpenSelect({
triggerProps: { clearable: true },
})
expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument()
})
it('should hide clear button when loading is true', () => {
renderOpenSelect({
triggerProps: { clearable: true, loading: true },
})
expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument()
})
it('should forward native trigger props when trigger props are provided', () => {
renderOpenSelect({
triggerProps: {
@ -94,48 +90,6 @@ describe('Select wrappers', () => {
expect(trigger).toBeDisabled()
})
it('should call onClear and stop click propagation when clear button is clicked', () => {
const onClear = vi.fn()
const onTriggerClick = vi.fn()
renderOpenSelect({
triggerProps: {
clearable: true,
onClear,
onClick: onTriggerClick,
},
})
fireEvent.click(screen.getByRole('button', { name: /clear selection/i }))
expect(onClear).toHaveBeenCalledTimes(1)
expect(onTriggerClick).not.toHaveBeenCalled()
})
it('should stop mouse down propagation when clear button receives mouse down', () => {
const onTriggerMouseDown = vi.fn()
renderOpenSelect({
triggerProps: {
clearable: true,
onMouseDown: onTriggerMouseDown,
},
})
fireEvent.mouseDown(screen.getByRole('button', { name: /clear selection/i }))
expect(onTriggerMouseDown).not.toHaveBeenCalled()
})
it('should not throw when clear button is clicked without onClear handler', () => {
renderOpenSelect({
triggerProps: { clearable: true },
})
const clearButton = screen.getByRole('button', { name: /clear selection/i })
expect(() => fireEvent.click(clearButton)).not.toThrow()
})
it('should apply regular size variant classes by default', () => {
renderOpenSelect()
@ -182,26 +136,6 @@ describe('Select wrappers', () => {
expect(trigger.className).toContain('data-disabled:data-placeholder:text-components-input-text-disabled')
})
it('should show error icon and apply destructive styling when variant is destructive', () => {
renderOpenSelect({
triggerProps: { variant: 'destructive' },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toContain('border-components-input-border-destructive')
expect(trigger.className).toContain('bg-components-input-bg-destructive')
const errorIcon = trigger.querySelector('.i-ri-error-warning-line')
expect(errorIcon).toBeInTheDocument()
})
it('should hide clear button when variant is destructive even if clearable', () => {
renderOpenSelect({
triggerProps: { clearable: true, variant: 'destructive' },
})
expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument()
})
it('should apply readonly styling via data attributes when Root is readOnly', () => {
renderOpenSelect({
rootProps: { readOnly: true },
@ -236,6 +170,14 @@ describe('Select wrappers', () => {
const trigger = screen.getByRole('combobox', { name: 'city select' })
expect(trigger.className).toContain('data-placeholder:text-components-input-text-placeholder')
})
it('should render built-in chevron icon', () => {
renderOpenSelect()
const trigger = screen.getByRole('combobox', { name: 'city select' })
const chevron = trigger.querySelector('.i-ri-arrow-down-s-line')
expect(chevron).toBeInTheDocument()
})
})
describe('SelectContent', () => {
@ -291,7 +233,10 @@ describe('Select wrappers', () => {
'onFocus': onListFocus,
}}
>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>,
)
@ -330,9 +275,13 @@ describe('Select wrappers', () => {
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectItem value="seattle">Seattle</SelectItem>
<SelectItem value="seattle">
<SelectItemText>Seattle</SelectItemText>
<SelectItemIndicator />
</SelectItem>
<SelectItem value="new-york" disabled aria-label="Disabled New York">
New York
<SelectItemText>New York</SelectItemText>
<SelectItemIndicator />
</SelectItem>
</SelectContent>
</Select>,
@ -342,5 +291,22 @@ describe('Select wrappers', () => {
expect(onValueChange).not.toHaveBeenCalled()
})
it('should support custom composition with SelectItemText without indicator', () => {
render(
<Select open defaultValue="a">
<SelectTrigger aria-label="custom select">
<SelectValue />
</SelectTrigger>
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
<SelectItem value="a" className="gap-2">
<SelectItemText>Custom Item</SelectItemText>
</SelectItem>
</SelectContent>
</Select>,
)
expect(screen.getByRole('option', { name: 'Custom Item' })).toBeInTheDocument()
})
})
})

View File

@ -1,115 +1,43 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import type { Placement } from '@/app/components/base/ui/placement'
import { Select as BaseSelect } from '@base-ui/react/select'
import { cn } from '@langgenius/dify-ui/cn'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import {
overlayGroupLabelClassName,
overlaySeparatorClassName,
} from '@/app/components/base/ui/overlay-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
export const Select = BaseSelect.Root
export const SelectValue = BaseSelect.Value
/** @public */
export const SelectGroup = BaseSelect.Group
/** @public */
export const SelectGroupLabel = BaseSelect.GroupLabel
/** @public */
export const SelectSeparator = BaseSelect.Separator
const selectTriggerVariants = cva(
'',
{
variants: {
size: {
small: 'h-6 gap-px rounded-md px-[5px] py-0 system-xs-regular',
regular: 'h-8 gap-0.5 rounded-lg px-2 py-1 system-sm-regular',
large: 'h-9 gap-0.5 rounded-[10px] px-2.5 py-1 system-md-regular',
},
variant: {
default: '',
destructive: 'border border-components-input-border-destructive bg-components-input-bg-destructive shadow-xs hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive',
},
},
defaultVariants: {
size: 'regular',
variant: 'default',
},
},
)
const contentPadding: Record<string, string> = {
small: 'px-[3px] py-1',
regular: 'p-1',
large: 'px-1.5 py-1',
const selectSizeClassName: Record<string, string> = {
small: 'h-6 gap-px rounded-md px-2 py-1 system-xs-regular',
regular: 'h-8 gap-0.5 rounded-lg px-3 py-2 system-sm-regular',
large: 'h-9 gap-0.5 rounded-[10px] px-4 py-2 system-md-regular',
}
type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger> & {
clearable?: boolean
onClear?: () => void
loading?: boolean
} & VariantProps<typeof selectTriggerVariants>
size?: 'small' | 'regular' | 'large'
}
export function SelectTrigger({
className,
children,
size = 'regular',
variant = 'default',
clearable = false,
onClear,
loading = false,
...props
}: SelectTriggerProps) {
const paddingClass = contentPadding[size ?? 'regular']
const isDestructive = variant === 'destructive'
let trailingIcon: React.ReactNode = null
if (loading) {
trailingIcon = (
<span className="shrink-0 text-text-quaternary" aria-hidden="true">
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
</span>
)
}
else if (isDestructive) {
trailingIcon = (
<span className="shrink-0 text-text-destructive-secondary" aria-hidden="true">
<span className="i-ri-error-warning-line h-4 w-4" />
</span>
)
}
else if (clearable) {
trailingIcon = (
<span
role="button"
aria-label="Clear selection"
tabIndex={-1}
className="shrink-0 cursor-pointer text-text-quaternary group-data-disabled:hidden group-data-readonly:hidden hover:text-text-secondary"
onClick={(e) => {
e.stopPropagation()
onClear?.()
}}
onMouseDown={e => e.stopPropagation()}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" aria-hidden="true" />
</span>
)
}
else {
trailingIcon = (
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
</BaseSelect.Icon>
)
}
return (
<BaseSelect.Trigger
className={cn(
'group relative flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt',
'data-placeholder:text-components-input-text-placeholder',
selectTriggerVariants({ size, variant }),
selectSizeClassName[size],
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
'data-disabled:data-placeholder:text-components-input-text-disabled',
@ -117,14 +45,41 @@ export function SelectTrigger({
)}
{...props}
>
<span className={cn('min-w-0 grow truncate', paddingClass)}>
<span className="min-w-0 grow truncate">
{children}
</span>
{trailingIcon}
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
</BaseSelect.Icon>
</BaseSelect.Trigger>
)
}
export function SelectGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.GroupLabel>) {
return (
<BaseSelect.GroupLabel
className={cn(overlayGroupLabelClassName, className)}
{...props}
/>
)
}
/** @public */
export function SelectSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Separator>) {
return (
<BaseSelect.Separator
className={cn(overlaySeparatorClassName, className)}
{...props}
/>
)
}
type SelectContentProps = {
children: React.ReactNode
placement?: Placement
@ -174,14 +129,14 @@ export function SelectContent({
>
<BaseSelect.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'min-w-(--anchor-width) rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
{...popupProps}
>
<BaseSelect.List
className={cn('max-h-80 min-w-40 overflow-auto p-1 outline-hidden', listClassName)}
className={cn('max-h-80 overflow-auto p-1 outline-hidden', listClassName)}
{...listProps}
>
{children}
@ -194,7 +149,6 @@ export function SelectContent({
export function SelectItem({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Item>) {
return (
@ -205,13 +159,32 @@ export function SelectItem({
className,
)}
{...props}
>
<BaseSelect.ItemText className="mr-1 min-w-0 grow truncate px-1">
{children}
</BaseSelect.ItemText>
<BaseSelect.ItemIndicator className="flex shrink-0 items-center text-text-accent">
<span className="i-ri-check-line h-4 w-4" aria-hidden="true" />
</BaseSelect.ItemIndicator>
</BaseSelect.Item>
/>
)
}
export function SelectItemText({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.ItemText>) {
return (
<BaseSelect.ItemText
className={cn('mr-1 min-w-0 grow truncate px-1', className)}
{...props}
/>
)
}
export function SelectItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.ItemIndicator>, 'children'>) {
return (
<BaseSelect.ItemIndicator
className={cn('ml-auto flex shrink-0 items-center text-text-accent', className)}
{...props}
>
<span className="i-ri-check-line h-4 w-4" aria-hidden />
</BaseSelect.ItemIndicator>
)
}

View File

@ -1,7 +1,6 @@
import type { ProviderContextState } from '@/context/provider-context'
import type { ReactNode } from 'react'
import type { IWorkspace } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { switchWorkspace } from '@/service/common'
import WorkplaceSelector from '../index'
@ -10,21 +9,30 @@ const toastMocks = vi.hoisted(() => ({
mockNotify: vi.fn(),
}))
type MockSelectState = {
value: string
onValueChange: (value: string | null) => void
}
const selectMocks = vi.hoisted(() => ({
state: {
value: '',
onValueChange: () => {},
} as MockSelectState,
reset: (): MockSelectState => ({
value: '',
onValueChange: () => {},
}),
}))
vi.mock('@/context/workspace-context', () => ({
useWorkspacesContext: vi.fn(),
}))
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/service/common', () => ({
switchWorkspace: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
default: {
notify: (args: unknown) => toastMocks.mockNotify(args),
@ -37,6 +45,52 @@ vi.mock('@/app/components/base/ui/toast', () => ({
},
}))
vi.mock('@/app/components/base/ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/select')>()
return {
...actual,
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string | null) => void
children: ReactNode
}) => {
selectMocks.state = { value, onValueChange }
return <div data-testid="workplace-selector-root">{children}</div>
},
SelectTrigger: ({ children }: { children: ReactNode }) => (
<button data-testid="workplace-selector-trigger" type="button">
{children}
</button>
),
SelectContent: ({ children }: { children: ReactNode }) => (
<div data-testid="workplace-selector-content">{children}</div>
),
SelectGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectGroupLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({
children,
value,
}: {
children: ReactNode
value: string
}) => (
<button
data-testid={`workspace-option-${value}`}
type="button"
onClick={() => selectMocks.state.onValueChange(value)}
>
{children}
</button>
),
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}
})
describe('WorkplaceSelector', () => {
const mockWorkspaces: IWorkspace[] = [
{ id: '1', name: 'Workspace 1', current: true, plan: 'professional', status: 'normal', created_at: Date.now() },
@ -48,68 +102,41 @@ describe('WorkplaceSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
selectMocks.state = selectMocks.reset()
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: mockWorkspaces,
})
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
isFetchedPlan: true,
isEducationWorkspace: false,
} as ProviderContextState)
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
})
const renderComponent = () => {
return render(
<>
<WorkplaceSelector />
</>,
)
}
const renderComponent = () => render(<WorkplaceSelector />)
describe('Rendering', () => {
it('should render current workspace correctly', () => {
// Act
it('should render current workspace and available workspace options', () => {
renderComponent()
// Assert
expect(screen.getByText('Workspace 1')).toBeInTheDocument()
expect(screen.getByText('W')).toBeInTheDocument() // First letter icon
})
it('should open menu and display all workspaces when clicked', () => {
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
// Assert
expect(screen.getAllByText('Workspace 1').length).toBeGreaterThan(0)
expect(screen.getByText('Workspace 2')).toBeInTheDocument()
// The real PlanBadge renders uppercase plan name or "pro"
expect(screen.getByText('pro')).toBeInTheDocument()
expect(screen.getByText('sandbox')).toBeInTheDocument()
expect(screen.getByTestId('workplace-selector-trigger')).toHaveTextContent('Workspace 1')
expect(screen.getByTestId('workspace-option-1')).toBeInTheDocument()
expect(screen.getByTestId('workspace-option-2')).toBeInTheDocument()
expect(screen.getByTestId('workspace-option-1')).toHaveTextContent('Workspace 1')
expect(screen.getByTestId('workspace-option-2')).toHaveTextContent('Workspace 2')
})
})
describe('Workspace Switching', () => {
it('should switch workspace successfully', async () => {
// Arrange
vi.mocked(switchWorkspace).mockResolvedValue({
result: 'success',
new_tenant: mockWorkspaces[1],
})
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspace2 = screen.getByText('Workspace 2')
fireEvent.click(workspace2)
fireEvent.click(screen.getByTestId('workspace-option-2'))
// Assert
expect(switchWorkspace).toHaveBeenCalledWith({
await waitFor(() => expect(switchWorkspace).toHaveBeenCalledWith({
url: '/workspaces/switch',
body: { tenant_id: '2' },
})
}))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
@ -121,27 +148,18 @@ describe('WorkplaceSelector', () => {
})
it('should not switch to the already current workspace', () => {
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspacesInMenu = screen.getAllByText('Workspace 1')
fireEvent.click(workspacesInMenu[workspacesInMenu.length - 1])
fireEvent.click(screen.getByTestId('workspace-option-1'))
// Assert
expect(switchWorkspace).not.toHaveBeenCalled()
})
it('should handle switching error correctly', async () => {
// Arrange
vi.mocked(switchWorkspace).mockRejectedValue(new Error('Failed'))
// Act
renderComponent()
fireEvent.click(screen.getByRole('button'))
const workspace2 = screen.getByText('Workspace 2')
fireEvent.click(workspace2)
fireEvent.click(screen.getByTestId('workspace-option-2'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
@ -152,29 +170,23 @@ describe('WorkplaceSelector', () => {
})
describe('Edge Cases', () => {
// find() returns undefined: no workspace with current: true
it('should not crash when no workspace has current: true', () => {
// Arrange
it('should not crash when no workspace has current value', () => {
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
// name[0]?.toLocaleUpperCase() undefined: workspace with empty name
it('should not crash when workspace name is empty string', () => {
// Arrange
vi.mocked(useWorkspacesContext).mockReturnValue({
workspaces: [
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
],
})
// Act & Assert - should not throw
expect(() => renderComponent()).not.toThrow()
})
})

View File

@ -1,9 +1,14 @@
import type { Plan } from '@/app/components/billing/type'
import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine } from '@remixicon/react'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectItemText,
SelectTrigger,
} from '@/app/components/base/ui/select'
import { toast } from '@/app/components/base/ui/toast'
import PlanBadge from '@/app/components/header/plan-badge'
import { useWorkspacesContext } from '@/context/workspace-context'
@ -14,6 +19,7 @@ const WorkplaceSelector = () => {
const { t } = useTranslation()
const { workspaces } = useWorkspacesContext()
const currentWorkspace = workspaces.find(v => v.current)
const handleSwitchWorkspace = async (tenant_id: string) => {
try {
if (currentWorkspace?.id === tenant_id)
@ -26,50 +32,48 @@ const WorkplaceSelector = () => {
toast.error(t('provider.saveFailed', { ns: 'common' }))
}
}
return (
<Menu as="div" className="min-w-0">
{({ open }) => (
<>
<MenuButton className={cn(`
group flex w-full cursor-pointer items-center
p-0.5 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} rounded-[10px]
`)}
>
<div className="mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="flex min-w-0 items-center">
<div className="max-w-[149px] min-w-0 truncate system-sm-medium text-text-secondary max-[800px]:hidden">{currentWorkspace?.name}</div>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-secondary" />
</div>
</MenuButton>
<Transition as={Fragment} enter="transition ease-out duration-100" enterFrom="transform opacity-0 scale-95" enterTo="transform opacity-100 scale-100" leave="transition ease-in duration-75" leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95">
<MenuItems
anchor="bottom start"
className={cn(`
shadows-shadow-lg absolute left-[-15px] z-[1000] mt-1 flex max-h-[400px] w-[280px] flex-col items-start overflow-y-auto
rounded-xl bg-components-panel-bg-blur backdrop-blur-[5px]
`)}
>
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg">
<div className="flex items-start self-stretch px-3 pt-1 pb-0.5">
<span className="flex-1 system-xs-medium-uppercase text-text-tertiary">{t('userProfile.workspace', { ns: 'common' })}</span>
</div>
{workspaces.map(workspace => (
<div className="flex items-center gap-2 self-stretch rounded-lg py-1 pr-2 pl-3 hover:bg-state-base-hover" key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">{workspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="line-clamp-1 grow cursor-pointer overflow-hidden system-md-regular text-ellipsis text-text-secondary">{workspace.name}</div>
<PlanBadge plan={workspace.plan as Plan} />
</div>
))}
<Select
value={currentWorkspace?.id ?? ''}
onValueChange={(value) => {
if (value)
void handleSwitchWorkspace(value)
}}
>
<SelectTrigger
className="w-auto cursor-pointer rounded-[10px] border-0 bg-transparent p-0.5 hover:bg-state-base-hover data-popup-open:bg-state-base-hover"
>
<div className="flex items-center">
<div className="mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{currentWorkspace?.name[0]?.toLocaleUpperCase()}
</span>
</div>
<div className="max-w-[149px] min-w-0 truncate system-sm-medium text-text-secondary max-[800px]:hidden">
{currentWorkspace?.name}
</div>
</div>
</SelectTrigger>
<SelectContent popupClassName="w-[280px]">
<SelectGroup>
<SelectGroupLabel>
{t('userProfile.workspace', { ns: 'common' })}
</SelectGroupLabel>
{workspaces.map(workspace => (
<SelectItem key={workspace.id} value={workspace.id} className="gap-2 py-1 pr-2 pl-3">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{workspace.name[0]?.toLocaleUpperCase()}
</span>
</div>
</MenuItems>
</Transition>
</>
)}
</Menu>
<SelectItemText className="system-md-regular">{workspace.name}</SelectItemText>
<PlanBadge plan={workspace.plan as Plan} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export default WorkplaceSelector

View File

@ -6,19 +6,26 @@ vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => (
<div>
<button type="button" onClick={() => onValueChange('updated')}>select-updated</button>
<button type="button" onClick={() => onValueChange(undefined)}>select-empty</button>
{children}
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
}))
vi.mock('@/app/components/base/ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/select')>()
return {
...actual,
Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => (
<div>
<button type="button" onClick={() => onValueChange('updated')}>select-updated</button>
<button type="button" onClick={() => onValueChange(undefined)}>select-empty</button>
{children}
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
SelectItemIndicator: () => <span data-testid="select-item-indicator" />,
}
})
describe('ParameterItem select mode', () => {
it('should propagate both explicit and empty select values', () => {

View File

@ -10,7 +10,7 @@ import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import Switch from '@/app/components/base/switch'
import TagInput from '@/app/components/base/tag-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Slider } from '@/app/components/base/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { BlockEnum } from '@/app/components/workflow/types'
@ -299,7 +299,10 @@ function ParameterItem({
</SelectTrigger>
<SelectContent>
{parameterRule.options!.map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
<SelectItem key={option} value={option}>
<SelectItemText>{option}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>

View File

@ -26,66 +26,83 @@ const MockSelectContext = React.createContext<{
onValueChange: () => {},
})
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<MockSelectContext.Provider value={{ value, onValueChange }}>
<div data-testid="select-root">{children}</div>
</MockSelectContext.Provider>
),
SelectTrigger: ({
children,
className,
'data-testid': testId,
}: {
'children': React.ReactNode
'className'?: string
'data-testid'?: string
}) => (
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
{children}
</button>
),
SelectValue: () => {
const { value } = React.useContext(MockSelectContext)
return <span data-testid="selected-value">{value}</span>
},
SelectContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => (
<div data-testid="select-content" data-popup-class={popupClassName}>
{children}
</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode
value: string
}) => {
const { onValueChange } = React.useContext(MockSelectContext)
return (
<button
data-testid={`select-item-${value}`}
onClick={() => onValueChange(value)}
>
vi.mock('@/app/components/base/ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/select')>()
return {
...actual,
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<MockSelectContext.Provider value={{ value, onValueChange }}>
<div data-testid="select-root">{children}</div>
</MockSelectContext.Provider>
),
SelectTrigger: ({
children,
className,
'data-testid': testId,
}: {
'children': React.ReactNode
'className'?: string
'data-testid'?: string
}) => (
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
{children}
</button>
)
},
}))
),
SelectValue: () => {
const { value } = React.useContext(MockSelectContext)
return <span data-testid="selected-value">{value}</span>
},
SelectContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => (
<div data-testid="select-content" data-popup-class={popupClassName}>
{children}
</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode
value: string
}) => {
const { onValueChange } = React.useContext(MockSelectContext)
return (
<button
data-testid={`select-item-${value}`}
onClick={() => onValueChange(value)}
>
{children}
</button>
)
},
SelectItemText: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => <span data-class={className}>{children}</span>,
SelectItemIndicator: ({
className,
}: {
className?: string
}) => <span data-testid="select-item-indicator" data-class={className} />,
}
})
// ==================== Test Utilities ====================

View File

@ -1,7 +1,7 @@
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { languages } from '@/i18n-config/language'
type Props = {
@ -37,7 +37,7 @@ const TTSParamsPanel = ({
return (
<>
<div className="mb-3">
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
<div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary">
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
</div>
<Select
@ -58,14 +58,15 @@ const TTSParamsPanel = ({
<SelectContent popupClassName="w-[354px]">
{supportedLanguages.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.name}
<SelectItemText>{item.name}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mb-3">
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
<div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary">
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
</div>
<Select
@ -86,7 +87,8 @@ const TTSParamsPanel = ({
<SelectContent popupClassName="w-[354px]">
{voiceList.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
<SelectItemText>{item.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>

View File

@ -4523,9 +4523,6 @@
}
},
"app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}