mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
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:
parent
883d757392
commit
b08665e598
@ -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 }) => (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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'
|
||||
7
web/app/components/base/ui/overlay-shared.ts
Normal file
7
web/app/components/base/ui/overlay-shared.ts
Normal 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'
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user