refactor(web): migrate headless-ui components to dify-ui (#35962)

This commit is contained in:
yyh 2026-05-09 14:49:26 +08:00 committed by GitHub
parent 2bb1f0906b
commit 2c9e30426d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 583 additions and 1163 deletions

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Nav from '@/app/components/header/nav'
@ -192,27 +193,23 @@ describe('Header Nav Flow', () => {
})
it('opens the nested create menu and emits all app creation branches', async () => {
renderNav()
fireEvent.click(screen.getByRole('button', { name: /Alpha/i }))
const openCreateMenu = async () => {
fireEvent.click(await screen.findByText('menus.newApp'))
return screen.findByText('newApp.startFromBlank')
const user = userEvent.setup()
const clickCreateBranch = async (optionName: string) => {
const { unmount } = renderNav()
await user.click(screen.getByRole('button', { name: /Alpha/i }))
await user.hover(await screen.findByRole('menuitem', { name: /menus\.newApp/i }))
fireEvent.click(await screen.findByRole('menuitem', { name: optionName }))
unmount()
}
await openCreateMenu()
fireEvent.click(await screen.findByText('newApp.startFromBlank'))
await openCreateMenu()
fireEvent.click(await screen.findByText('newApp.startFromTemplate'))
await openCreateMenu()
fireEvent.click(await screen.findByText('importDSL'))
await clickCreateBranch('newApp.startFromBlank')
await clickCreateBranch('newApp.startFromTemplate')
await clickCreateBranch('importDSL')
expect(mockOnCreate).toHaveBeenNthCalledWith(1, 'blank')
expect(mockOnCreate).toHaveBeenNthCalledWith(2, 'template')
expect(mockOnCreate).toHaveBeenNthCalledWith(3, 'dsl')
expect(mockOnCreate).toHaveBeenCalledTimes(3)
})
it('keeps the current nav label in sync with prop updates', async () => {

View File

@ -1,11 +1,13 @@
'use client'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiGraduationCapFill,
} from '@remixicon/react'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
@ -38,73 +40,48 @@ export default function AppSelector() {
}
return (
<Menu as="div" className="relative inline-block text-left">
{
({ open }) => (
<>
<div>
<MenuButton
className={`
p-1x inline-flex
items-center rounded-[20px] text-sm
text-text-primary
mobile:px-1
${open && 'bg-components-panel-bg-blur'}
`}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} />
</MenuButton>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={userProfile.name}
className={cn(
'inline-flex items-center rounded-[20px] text-sm text-text-primary outline-hidden mobile:px-1',
'hover:bg-components-panel-bg-blur focus-visible:bg-components-panel-bg-blur focus-visible:ring-1 focus-visible:ring-components-input-border-hover data-popup-open:bg-components-panel-bg-blur',
)}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-60 max-w-80 divide-y divide-divider-subtle bg-components-panel-bg-blur p-0"
>
<div className="p-1">
<div className="flex flex-nowrap items-center px-3 py-2">
<div className="min-w-0 grow">
<div className="system-md-medium break-all text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 px-2!">
<span className="mr-1 i-ri-graduation-cap-fill h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div>
</div>
<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
className="
absolute -top-1 -right-2 w-60 max-w-80
origin-top-right divide-y divide-divider-subtle rounded-lg bg-components-panel-bg-blur
shadow-lg
"
>
<MenuItem>
<div className="p-1">
<div className="flex flex-nowrap items-center px-3 py-2">
<div className="grow">
<div className="system-md-medium break-all text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 px-2!">
<RiGraduationCapFill className="mr-1 h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} />
</div>
</div>
</MenuItem>
<MenuItem>
<div className="p-1" onClick={() => handleLogout()}>
<div
className="group flex h-9 cursor-pointer items-center justify-start rounded-lg px-3 hover:bg-state-base-hover"
>
<LogOut01 className="mr-1 flex h-4 w-4 text-text-tertiary" />
<div className="text-[14px] font-normal text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</div>
</div>
</div>
</MenuItem>
</MenuItems>
</Transition>
</>
)
}
</Menu>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} />
</div>
</div>
<div className="p-1">
<DropdownMenuItem
className="h-9 justify-start px-3"
onClick={handleLogout}
>
<LogOut01 className="mr-1 flex h-4 w-4 text-text-tertiary" />
<span className="text-[14px] font-normal text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</span>
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -3,7 +3,7 @@ import type { ComponentProps } from 'react'
import type { Mock } from 'vitest'
import type { AnnotationItemBasic } from '../../type'
import type { Locale } from '@/i18n-config'
import { render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useLocale } from '@/context/i18n'
@ -128,21 +128,15 @@ vi.mock('@headlessui/react', () => {
}
})
let lastCSVDownloaderProps: Record<string, unknown> | undefined
const mockCSVDownloader = vi.fn(({ children, ...props }) => {
lastCSVDownloaderProps = props
return (
<div data-testid="csv-downloader">
{children}
</div>
)
})
const mockJsonToCSV = vi.fn((_: unknown) => 'csv-content')
const mockCSVDownloader = vi.fn(({ children }) => <>{children}</>)
vi.mock('react-papaparse', () => ({
useCSVDownloader: () => ({
CSVDownloader: (props: any) => mockCSVDownloader(props),
Type: { Link: 'link' },
}),
jsonToCSV: (data: unknown) => mockJsonToCSV(data),
}))
vi.mock('@/service/annotation', () => ({
@ -194,33 +188,28 @@ const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) =
const expandExportMenu = async (user: ReturnType<typeof userEvent.setup>) => {
await openOperationsPopover(user)
const exportLabel = await screen.findByText('appAnnotation.table.header.bulkExport')
const exportButton = exportLabel.closest('button') as HTMLButtonElement
expect(exportButton).toBeTruthy()
await user.click(exportButton)
const exportItem = await screen.findByRole('menuitem', { name: /appAnnotation\.table\.header\.bulkExport/i })
await user.hover(exportItem)
}
const getExportButtons = async () => {
const csvLabel = await screen.findByText('CSV')
const jsonLabel = await screen.findByText('JSONL')
const csvButton = csvLabel.closest('button') as HTMLButtonElement
const jsonButton = jsonLabel.closest('button') as HTMLButtonElement
expect(csvButton).toBeTruthy()
expect(jsonButton).toBeTruthy()
const getExportItems = async () => {
const csvItem = await screen.findByRole('menuitem', { name: 'CSV' })
const jsonItem = await screen.findByRole('menuitem', { name: 'JSONL' })
return {
csvButton,
jsonButton,
csvItem,
jsonItem,
}
}
const clickOperationAction = async (
user: ReturnType<typeof userEvent.setup>,
translationKey: string,
) => {
const label = await screen.findByText(translationKey)
const button = label.closest('button') as HTMLButtonElement
expect(button).toBeTruthy()
await user.click(button)
const clickMenuItem = async (item: HTMLElement) => {
await act(async () => {
item.click()
})
}
const clickOperationAction = async (translationKey: string) => {
const item = await screen.findByRole('menuitem', { name: translationKey })
await clickMenuItem(item)
}
const mockAnnotations: AnnotationItemBasic[] = [
@ -237,11 +226,14 @@ describe('HeaderOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
mockCSVDownloader.mockClear()
lastCSVDownloaderProps = undefined
mockJsonToCSV.mockReturnValue('csv-content')
mockedFetchAnnotations.mockResolvedValue({ data: [] })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should fetch annotations on mount and render enabled export actions when data exist', async () => {
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
const user = userEvent.setup()
@ -253,22 +245,69 @@ describe('HeaderOptions', () => {
await expandExportMenu(user)
const { csvButton, jsonButton } = await getExportButtons()
const { csvItem, jsonItem } = await getExportItems()
expect(csvButton).not.toBeDisabled()
expect(jsonButton).not.toBeDisabled()
expect(csvItem).not.toHaveAttribute('data-disabled')
expect(jsonItem).not.toHaveAttribute('data-disabled')
await waitFor(() => {
expect(lastCSVDownloaderProps).toMatchObject({
bom: true,
filename: 'annotations-en-US',
type: 'link',
data: [
['Question', 'Answer'],
['Question 1', 'Answer 1'],
],
await clickMenuItem(csvItem)
expect(mockJsonToCSV).toHaveBeenCalledWith([
['Question', 'Answer'],
['Question 1', 'Answer 1'],
])
})
it('should trigger CSV download with locale-specific filename', async () => {
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
const user = userEvent.setup()
const originalCreateElement = document.createElement.bind(document)
const anchor = originalCreateElement('a') as HTMLAnchorElement
const clickSpy = vi.spyOn(anchor, 'click').mockImplementation(vi.fn())
const createElementSpy = vi.spyOn(document, 'createElement')
.mockImplementation((tagName: Parameters<Document['createElement']>[0]) => {
if (tagName === 'a')
return anchor
return originalCreateElement(tagName)
})
let capturedBlob: Blob | null = null
const objectURLSpy = vi.spyOn(URL, 'createObjectURL')
.mockImplementation((blob) => {
capturedBlob = blob as Blob
return 'blob://mock-url'
})
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(vi.fn())
renderComponent({}, LanguagesSupported[1])
await expandExportMenu(user)
const { csvItem } = await getExportItems()
await clickMenuItem(csvItem)
expect(mockJsonToCSV).toHaveBeenCalledWith([
['问题', '答案'],
['Question 1', 'Answer 1'],
])
expect(createElementSpy).toHaveBeenCalled()
expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.csv`)
expect(clickSpy).toHaveBeenCalled()
expect(revokeSpy).toHaveBeenCalledWith('blob://mock-url')
expect(capturedBlob).toBeInstanceOf(Blob)
expect(capturedBlob!.type).toBe('text/csv;charset=utf-8;')
const blobContent = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsText(capturedBlob!)
})
expect(blobContent).toBe('csv-content')
clickSpy.mockRestore()
createElementSpy.mockRestore()
objectURLSpy.mockRestore()
revokeSpy.mockRestore()
})
it('should disable export actions when there are no annotations', async () => {
@ -277,14 +316,11 @@ describe('HeaderOptions', () => {
await expandExportMenu(user)
const { csvButton, jsonButton } = await getExportButtons()
const { csvItem, jsonItem } = await getExportItems()
expect(csvButton)!.toBeDisabled()
expect(jsonButton)!.toBeDisabled()
expect(lastCSVDownloaderProps).toMatchObject({
data: [['Question', 'Answer']],
})
expect(csvItem).toHaveAttribute('data-disabled')
expect(jsonItem).toHaveAttribute('data-disabled')
expect(mockJsonToCSV).not.toHaveBeenCalled()
})
it('should open the add annotation modal and forward the onAdd callback', async () => {
@ -321,7 +357,7 @@ describe('HeaderOptions', () => {
renderComponent({ onAdded })
await openOperationsPopover(user)
await clickOperationAction(user, 'appAnnotation.table.header.bulkImport')
await clickOperationAction('appAnnotation.table.header.bulkImport')
expect(await screen.findByText('appAnnotation.batchModal.title'))!.toBeInTheDocument()
await user.click(
@ -354,10 +390,8 @@ describe('HeaderOptions', () => {
await expandExportMenu(user)
await waitFor(() => expect(mockCSVDownloader).toHaveBeenCalled())
const { jsonButton } = await getExportButtons()
await user.click(jsonButton)
const { jsonItem } = await getExportItems()
await clickMenuItem(jsonItem)
expect(createElementSpy).toHaveBeenCalled()
expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.jsonl`)
@ -396,7 +430,7 @@ describe('HeaderOptions', () => {
renderComponent({ onAdded })
await openOperationsPopover(user)
await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
await clickOperationAction('appAnnotation.table.header.clearAll')
await screen.findByText('appAnnotation.table.header.clearAllConfirm')
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
@ -416,7 +450,7 @@ describe('HeaderOptions', () => {
renderComponent({ onAdded })
await openOperationsPopover(user)
await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
await clickOperationAction('appAnnotation.table.header.clearAll')
await screen.findByText('appAnnotation.table.header.clearAllConfirm')
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
await user.click(confirmButton)

View File

@ -1,19 +1,21 @@
'use client'
import type { FC } from 'react'
import type { AnnotationItemBasic } from '../type'
import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { Fragment, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
useCSVDownloader,
jsonToCSV,
} from 'react-papaparse'
import { useLocale } from '@/context/i18n'
@ -54,6 +56,15 @@ const downloadAnnotationJsonl = (list: AnnotationItemBasic[], locale: string) =>
downloadBlob({ data: file, fileName: `annotations-${locale}.jsonl` })
}
const downloadAnnotationCsv = (list: AnnotationItemBasic[], locale: string) => {
const content = jsonToCSV([
locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN,
...list.map(item => [item.question, item.answer]),
])
const file = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' })
downloadBlob({ data: file, fileName: `annotations-${locale}.csv` })
}
const OperationsMenu: FC<OperationsMenuProps> = ({
list,
onClose,
@ -63,88 +74,62 @@ const OperationsMenu: FC<OperationsMenuProps> = ({
}) => {
const { t } = useTranslation()
const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader()
const annotationUnavailable = list.length === 0
return (
<div className="w-full py-1">
<button
type="button"
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"
<>
<DropdownMenuItem
className="gap-2"
onClick={() => {
onClose()
onBulkImport()
}}
>
<span aria-hidden className="i-custom-vender-line-files-file-plus-02 h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkImport', { ns: 'appAnnotation' })}</span>
</button>
<Menu as="div" className="relative h-full w-full">
<MenuButton className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50">
<span aria-hidden className="i-custom-vender-line-files-file-download-02 h-4 w-4 text-text-tertiary" />
<span className="grow text-left system-sm-regular text-text-secondary">{t('table.header.bulkExport', { ns: 'appAnnotation' })}</span>
<span aria-hidden className="i-custom-vender-line-arrows-chevron-right h-[14px] w-[14px] shrink-0 text-text-tertiary" />
</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"
<span aria-hidden className="i-custom-vender-line-files-file-plus-02 size-4 shrink-0 text-text-tertiary" />
{t('table.header.bulkImport', { ns: 'appAnnotation' })}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<span aria-hidden className="i-custom-vender-line-files-file-download-02 size-4 shrink-0 text-text-tertiary" />
{t('table.header.bulkExport', { ns: 'appAnnotation' })}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
placement="left-start"
sideOffset={4}
popupClassName="min-w-[100px]"
>
<MenuItems
className={cn(
'absolute top-px left-1 z-10 min-w-[100px] origin-top-right -translate-x-full rounded-xl border-[0.5px] border-components-panel-on-panel-item-bg bg-components-panel-bg py-1 shadow-xs',
)}
<DropdownMenuItem
disabled={annotationUnavailable}
onClick={() => {
onClose()
downloadAnnotationCsv(list, locale)
}}
>
<CSVDownloader
type={Type.Link}
filename={`annotations-${locale}`}
bom={true}
data={[
locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN,
...list.map(item => [item.question, item.answer]),
]}
>
<button
type="button"
disabled={annotationUnavailable}
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50"
onClick={onClose}
>
<span className="grow text-left system-sm-regular text-text-secondary">CSV</span>
</button>
</CSVDownloader>
<button
type="button"
disabled={annotationUnavailable}
className={cn('mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50', 'border-0!')}
onClick={() => {
onClose()
onExportJsonl()
}}
>
<span className="grow text-left system-sm-regular text-text-secondary">JSONL</span>
</button>
</MenuItems>
</Transition>
</Menu>
<button
type="button"
CSV
</DropdownMenuItem>
<DropdownMenuItem
disabled={annotationUnavailable}
onClick={() => {
onClose()
onExportJsonl()
}}
>
JSONL
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
variant="destructive"
className="gap-2"
onClick={() => {
onClose()
onClearAll()
}}
className="mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50"
>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
<span className="grow text-left system-sm-regular">
{t('table.header.clearAll', { ns: 'appAnnotation' })}
</span>
</button>
</div>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
{t('table.header.clearAll', { ns: 'appAnnotation' })}
</DropdownMenuItem>
</>
)
}
@ -204,7 +189,7 @@ const HeaderOptions: FC<Props> = ({
<span aria-hidden className="mr-0.5 i-ri-add-line h-4 w-4" />
<div>{t('table.header.addAnnotation', { ns: 'appAnnotation' })}</div>
</Button>
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className="mr-0 box-border inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-0 text-components-button-secondary-text shadow-xs backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover data-popup-open:border-components-button-secondary-border-hover data-popup-open:bg-components-button-secondary-bg-hover"
@ -214,7 +199,7 @@ const HeaderOptions: FC<Props> = ({
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[155px] overflow-visible py-0"
popupClassName="w-[155px]"
>
<OperationsMenu
list={list}

View File

@ -21,8 +21,7 @@ describe('AccessControlDialog', () => {
</AccessControlDialog>,
)
const closeButton = document.body.querySelector('div.absolute.right-5.top-5') as HTMLElement
fireEvent.click(closeButton)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1)

View File

@ -176,7 +176,7 @@ describe('AccessControlItem', () => {
})
})
// AccessControlDialog renders a headless UI dialog with a manual close control
// AccessControlDialog renders the shared dialog primitive with a close control.
describe('AccessControlDialog', () => {
it('should render dialog content when visible', () => {
render(
@ -191,13 +191,13 @@ describe('AccessControlDialog', () => {
it('should trigger onClose when clicking the close control', async () => {
const handleClose = vi.fn()
const { container } = render(
render(
<AccessControlDialog show onClose={handleClose}>
<div>Dialog Content</div>
</AccessControlDialog>,
)
const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement
const closeButton = screen.getByRole('button', { name: 'Close' })
fireEvent.click(closeButton)
await waitFor(() => {

View File

@ -1,8 +1,11 @@
import type { ReactNode } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine } from '@remixicon/react'
import { Fragment, useCallback } from 'react'
import {
Dialog,
DialogCloseButton,
DialogContent,
} from '@langgenius/dify-ui/dialog'
import { useCallback } from 'react'
type DialogProps = {
className?: string
@ -21,40 +24,12 @@ const AccessControlDialog = ({
onClose?.()
}, [onClose])
return (
<Transition appear show={show} as={Fragment}>
<Dialog as="div" open={true} className="relative z-99" onClose={() => null}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-background-overlay" />
</Transition.Child>
<div className="fixed inset-0 flex items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className={cn('relative h-auto min-h-[323px] w-[600px] overflow-y-auto rounded-2xl bg-components-panel-bg p-0 shadow-xl transition-all', className)}>
<div onClick={() => close()} className="absolute top-5 right-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center">
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
<Dialog open={show} onOpenChange={open => !open && close()}>
<DialogContent className={cn('min-h-[323px] w-[600px] p-0', className)}>
<DialogCloseButton className="top-5 right-5 h-8 w-8" />
{children}
</DialogContent>
</Dialog>
)
}

View File

@ -1,8 +1,8 @@
'use client'
import type { Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { Description as DialogDescription, DialogTitle } from '@headlessui/react'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'

View File

@ -64,6 +64,9 @@ const renderWithProvider = (
)
}
const getLanguageSelect = () => screen.getByRole('combobox', { name: /voice\.voiceSettings\.language/ })
const getVoiceSelect = () => screen.getByRole('combobox', { name: /voice\.voiceSettings\.voice/ })
describe('ParamConfigContent', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -116,16 +119,13 @@ describe('ParamConfigContent', () => {
it('should display language listbox button', () => {
renderWithProvider()
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
expect(getLanguageSelect()).toBeInTheDocument()
})
it('should display current voice in listbox button', () => {
renderWithProvider()
const buttons = screen.getAllByRole('button')
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
expect(voiceButton)!.toBeInTheDocument()
expect(getVoiceSelect()).toHaveTextContent('Alloy')
})
it('should render audition button when language has example', () => {
@ -152,8 +152,7 @@ describe('ParamConfigContent', () => {
text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled },
})
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
expect(getLanguageSelect()).toBeInTheDocument()
})
it('should render with no voice set and use first as default', () => {
@ -161,9 +160,7 @@ describe('ParamConfigContent', () => {
text2speech: { enabled: true, language: 'en-US', voice: 'nonexistent', autoPlay: TtsAutoPlay.disabled },
})
const buttons = screen.getAllByRole('button')
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
expect(voiceButton)!.toBeInTheDocument()
expect(getVoiceSelect()).toHaveTextContent('Alloy')
})
})
@ -239,10 +236,7 @@ describe('ParamConfigContent', () => {
it('should open language listbox and show options', async () => {
renderWithProvider()
const buttons = screen.getAllByRole('button')
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
expect(languageButton).toBeDefined()
await userEvent.click(languageButton!)
await userEvent.click(getLanguageSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThanOrEqual(2)
@ -252,10 +246,7 @@ describe('ParamConfigContent', () => {
const onChange = vi.fn()
renderWithProvider({ onChange })
const buttons = screen.getAllByRole('button')
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
expect(languageButton).toBeDefined()
await userEvent.click(languageButton!)
await userEvent.click(getLanguageSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThan(1)
await userEvent.click(options[1]!)
@ -266,10 +257,7 @@ describe('ParamConfigContent', () => {
const onChange = vi.fn()
renderWithProvider({ onChange })
const buttons = screen.getAllByRole('button')
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
expect(voiceButton).toBeDefined()
await userEvent.click(voiceButton!)
await userEvent.click(getVoiceSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThan(1)
await userEvent.click(options[1]!)
@ -279,10 +267,7 @@ describe('ParamConfigContent', () => {
it('should show selected language option in listbox', async () => {
renderWithProvider()
const buttons = screen.getAllByRole('button')
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
expect(languageButton).toBeDefined()
await userEvent.click(languageButton!)
await userEvent.click(getLanguageSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThanOrEqual(1)
@ -294,10 +279,7 @@ describe('ParamConfigContent', () => {
it('should show selected voice option in listbox', async () => {
renderWithProvider()
const buttons = screen.getAllByRole('button')
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
expect(voiceButton).toBeDefined()
await userEvent.click(voiceButton!)
await userEvent.click(getVoiceSelect())
const options = await screen.findAllByRole('option')
expect(options.length).toBeGreaterThanOrEqual(1)
@ -320,11 +302,7 @@ describe('ParamConfigContent', () => {
const placeholderTexts = screen.getAllByText(/placeholder\.select/)
expect(placeholderTexts.length).toBeGreaterThanOrEqual(2)
const disabledButtons = screen
.getAllByRole('button')
.filter(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true')
expect(disabledButtons.length).toBeGreaterThanOrEqual(1)
expect(getVoiceSelect()).toHaveAttribute('data-disabled')
})
it('should call useAppVoices with empty appId when pathname has no app segment', () => {

View File

@ -1,11 +1,15 @@
'use client'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { Switch } from '@langgenius/dify-ui/switch'
import { produce } from 'immer'
import * as React from 'react'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { replace } from 'string-ts'
import AudioBtn from '@/app/components/base/audio-btn'
@ -35,6 +39,9 @@ const VoiceParamConfig = ({
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const text2speech = useFeatures(state => state.features.text2speech)
const featuresStore = useFeaturesStore()
const formatLanguageName = (item: SelectOption) => {
return t(`voice.language.${replace(String(item.value), '-', '')}`, item.name, { ns: 'common' as const })
}
let languageItem = languages.find(item => item.value === text2speech?.language)
if (languages && !languageItem)
@ -70,21 +77,14 @@ const VoiceParamConfig = ({
<>
<div className="mb-4 flex items-center justify-between">
<div className="system-xl-semibold text-text-primary">{t('voice.voiceSettings.title', { ns: 'appDebug' })}</div>
<div
className="cursor-pointer p-1"
role="button"
tabIndex={0}
<button
type="button"
className="rounded-md p-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
aria-label={t('appDebug:voice.voiceSettings.close')}
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClose()
}
}}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
</div>
<div className="mb-3">
<div className="mb-1 flex items-center py-1 system-sm-semibold text-text-secondary">
@ -100,129 +100,63 @@ const VoiceParamConfig = ({
))}
</Infotip>
</div>
<Listbox
value={languageItem}
onChange={(value: SelectOption) => {
<Select
value={languageItem ? String(languageItem.value) : null}
onValueChange={(nextValue) => {
if (!nextValue)
return
handleChange({
language: String(value.value),
language: nextValue,
})
}}
>
<div className="relative h-8">
<ListboxButton
className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pr-10 pl-3 group-hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden sm:text-sm sm:leading-6"
>
<span className={cn('block truncate text-left text-text-secondary', !languageItem?.name && 'text-text-tertiary')}>
{languageItem?.name
? t(`voice.language.${replace(languageItem?.value ?? '', '-', '')}`, languageItem?.name, { ns: 'common' as const })
: localLanguagePlaceholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm"
>
{languages.map(item => (
<ListboxOption
key={item.value}
className="relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover data-active:bg-state-base-active"
value={item}
disabled={false}
>
{({ /* active, */ selected }) => (
<>
<span
className={cn('block', selected && 'font-normal')}
>
{t(`voice.language.${replace((item.value), '-', '')}`, item.name, { ns: 'common' as const })}
</span>
{(selected || item.value === text2speech?.language) && (
<span
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
>
<span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div>
</Listbox>
<SelectTrigger aria-label={t('voice.voiceSettings.language', { ns: 'appDebug' })} className="w-full">
{languageItem ? formatLanguageName(languageItem) : localLanguagePlaceholder}
</SelectTrigger>
<SelectContent listClassName="max-h-60">
{languages.map(item => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>
{formatLanguageName(item)}
</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mb-3">
<div className="mb-1 py-1 system-sm-semibold text-text-secondary">
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
</div>
<div className="flex items-center gap-1">
<Listbox
value={voiceItem}
<Select
value={voiceItem ? String(voiceItem.value) : null}
disabled={!languageItem}
onChange={(value: SelectOption) => {
onValueChange={(nextValue) => {
if (!nextValue)
return
handleChange({
voice: String(value.value),
voice: nextValue,
})
}}
>
<div className="relative h-8 grow">
<ListboxButton
className="h-full w-full cursor-pointer rounded-lg border-0 bg-components-input-bg-normal py-1.5 pr-10 pl-3 group-hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden sm:text-sm sm:leading-6"
>
<span
className={cn('block truncate text-left text-text-secondary', !voiceItem?.name && 'text-text-tertiary')}
>
{voiceItem?.name ?? localVoicePlaceholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-hidden sm:text-sm"
>
{voiceItems?.map((item: SelectOption) => (
<ListboxOption
key={item.value}
className="relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover data-active:bg-state-base-active"
value={item}
disabled={false}
>
{({ /* active, */ selected }) => (
<>
<span className={cn('block', selected && 'font-normal')}>{item.name}</span>
{(selected || item.value === text2speech?.voice) && (
<span
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
>
<span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
<div className="grow">
<SelectTrigger aria-label={t('voice.voiceSettings.voice', { ns: 'appDebug' })} className="w-full">
{voiceItem?.name ?? localVoicePlaceholder}
</SelectTrigger>
<SelectContent listClassName="max-h-60">
{voiceItems?.map((item: SelectOption) => (
<SelectItem key={item.value} value={String(item.value)}>
<SelectItemText>
{item.name}
</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</div>
</Listbox>
</Select>
{languageItem?.example && (
<div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1" data-testid="audition-button">
<AudioBtn
@ -253,4 +187,4 @@ const VoiceParamConfig = ({
)
}
export default React.memo(VoiceParamConfig)
export default VoiceParamConfig

View File

@ -1,7 +1,12 @@
'use client'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import * as React from 'react'
import { Fragment, useMemo } from 'react'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
export type NotionCredential = {
@ -17,99 +22,66 @@ type CredentialSelectorProps = {
onSelect: (v: string) => void
}
const getDisplayName = (item?: NotionCredential) => {
return item?.workspaceName || item?.credentialName || ''
}
const CredentialSelector = ({
value,
items,
onSelect,
}: CredentialSelectorProps) => {
const currentCredential = items.find(item => item.credentialId === value)!
const getDisplayName = (item: NotionCredential) => {
return item.workspaceName || item.credentialName
}
const currentDisplayName = useMemo(() => {
return getDisplayName(currentCredential)
}, [currentCredential])
const currentCredential = items.find(item => item.credentialId === value) ?? items[0]
const currentDisplayName = getDisplayName(currentCredential)
return (
<Menu as="div" className="relative inline-block text-left">
{
({ open }) => (
<>
<MenuButton
className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`}
data-testid="notion-credential-selector-btn"
<Select value={currentCredential?.credentialId ?? null} onValueChange={nextValue => nextValue && onSelect(nextValue)}>
<SelectTrigger
className="w-[168px]"
data-testid="notion-credential-selector-btn"
>
<span className="flex min-w-0 items-center">
<CredentialIcon
className="mr-2 shrink-0"
avatarUrl={currentCredential?.workspaceIcon}
name={currentDisplayName}
size={20}
/>
<span
className="truncate"
title={currentDisplayName}
data-testid="notion-credential-selector-name"
>
{currentDisplayName}
</span>
</span>
</SelectTrigger>
<SelectContent popupClassName="w-80" listClassName="max-h-50">
{items.map((item) => {
const displayName = getDisplayName(item)
return (
<SelectItem
key={item.credentialId}
value={item.credentialId}
className="h-9 px-3"
data-testid={`notion-credential-item-${item.credentialId}`}
>
<CredentialIcon
className="mr-2"
avatarUrl={currentCredential?.workspaceIcon}
name={currentDisplayName}
className="mr-2 shrink-0"
avatarUrl={item.workspaceIcon}
name={displayName}
size={20}
/>
<div
className="mr-1 w-[90px] truncate text-left text-sm font-medium text-text-secondary"
title={currentDisplayName}
data-testid="notion-credential-selector-name"
>
{currentDisplayName}
</div>
<div className="i-ri-arrow-down-s-line h-4 w-4 text-text-secondary" />
</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
className="absolute top-8 left-0 z-10 w-80
origin-top-right rounded-lg border-[0.5px]
border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5"
>
<div className="max-h-50 overflow-auto p-1">
{
items.map((item) => {
const displayName = getDisplayName(item)
return (
<MenuItem key={item.credentialId}>
<div
className="flex h-9 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
onClick={() => onSelect(item.credentialId)}
data-testid={`notion-credential-item-${item.credentialId}`}
>
<CredentialIcon
className="mr-2 shrink-0"
avatarUrl={item.workspaceIcon}
name={displayName}
size={20}
/>
<div
className="mr-2 grow truncate system-sm-medium text-text-secondary"
title={displayName}
>
{displayName}
</div>
{/* // ?Cannot get page length with new auth system */}
{/* <div className='system-xs-medium shrink-0 text-text-accent'>
{item.pages.length} {t('common.dataSource.notion.selector.pageSelected')}
</div> */}
</div>
</MenuItem>
)
})
}
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
<SelectItemText title={displayName}>
{displayName}
</SelectItemText>
<SelectItemIndicator />
</SelectItem>
)
})}
</SelectContent>
</Select>
)
}
export default React.memo(CredentialSelector)
export default CredentialSelector

View File

@ -1,11 +1,15 @@
'use client'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
@ -31,39 +35,29 @@ const TransferOwnership = ({ onOperate }: Props) => {
}
return (
<Menu as="div" className="relative h-full w-full">
{
({ open }) => (
<>
<MenuButton className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{t('members.owner', { ns: 'common' })}
<RiArrowDownSLine className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} />
</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
className={cn('absolute top-[52px] right-0 z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs')}
>
<div className="p-1">
<MenuItem>
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={onOperate}>
<div className="system-md-regular whitespace-nowrap text-text-secondary">{t('members.transferOwnership', { ns: 'common' })}</div>
</div>
</MenuItem>
</div>
</MenuItems>
</Transition>
</>
)
}
</Menu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
'group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary outline-hidden',
'hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover data-popup-open:bg-state-base-hover',
)}
>
{t('members.owner', { ns: 'common' })}
<RiArrowDownSLine className="hidden h-4 w-4 group-hover:block group-data-popup-open:block" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="bg-components-panel-bg-blur p-1 backdrop-blur-xs"
>
<DropdownMenuItem
className="h-auto px-3 py-2"
onClick={onOperate}
>
<span className="system-md-regular whitespace-nowrap text-text-secondary">{t('members.transferOwnership', { ns: 'common' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,29 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import PrioritySelector from '../priority-selector'
describe('PrioritySelector', () => {
const mockOnSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render selector button', () => {
render(<PrioritySelector value="system" onSelect={mockOnSelect} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should call onSelect when option clicked', () => {
render(<PrioritySelector value="system" onSelect={mockOnSelect} />)
fireEvent.click(screen.getByRole('button'))
const option = screen.getByText('common.modelProvider.apiKey')
fireEvent.click(option)
expect(mockOnSelect).toHaveBeenCalled()
})
it('should display priority use header in popover', () => {
render(<PrioritySelector value="custom" onSelect={mockOnSelect} />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('common.modelProvider.card.priorityUse')).toBeInTheDocument()
})
})

View File

@ -1,77 +0,0 @@
import type { FC } from 'react'
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiCheckLine,
RiMoreFill,
} from '@remixicon/react'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { PreferredProviderTypeEnum } from '../declarations'
type SelectorProps = {
value?: string
onSelect: (key: PreferredProviderTypeEnum) => void
}
const Selector: FC<SelectorProps> = ({
value,
onSelect,
}) => {
const { t } = useTranslation()
const options = [
{
key: PreferredProviderTypeEnum.custom,
text: t('modelProvider.apiKey', { ns: 'common' }),
},
{
key: PreferredProviderTypeEnum.system,
text: t('modelProvider.quota', { ns: 'common' }),
},
]
return (
<Popover className="relative">
<PopoverButton as="div">
{
({ open }) => (
<Button className={cn(
'h-6 w-6 rounded-md px-0',
open && 'bg-components-button-secondary-bg-hover',
)}
>
<RiMoreFill className="h-3 w-3" />
</Button>
)
}
</PopoverButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<PopoverPanel className="absolute top-7 right-0 z-10 w-[144px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div className="px-3 pt-2 pb-1 text-sm font-medium text-text-secondary">{t('modelProvider.card.priorityUse', { ns: 'common' })}</div>
{
options.map(option => (
<PopoverButton as={Fragment} key={option.key}>
<div
className="flex h-9 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-components-panel-on-panel-item-bg-hover"
onClick={() => onSelect(option.key)}
>
<div className="grow">{option.text}</div>
{value === option.key && <RiCheckLine className="h-4 w-4 text-text-accent" />}
</div>
</PopoverButton>
))
}
</div>
</PopoverPanel>
</Transition>
</Popover>
)
}
export default Selector

View File

@ -1,172 +0,0 @@
import type { AppDetailResponse } from '@/models/app'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import AppSelector from '../index'
// Mock next/navigation
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
}))
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
// Mock CreateAppDialog to avoid complex dependencies
vi.mock('@/app/components/app/create-app-dialog', () => ({
default: ({ show, onClose }: { show: boolean, onClose: () => void }) => show
? (
<div data-testid="create-app-dialog">
<button onClick={onClose}>Close</button>
</div>
)
: null,
}))
describe('AppSelector Component', () => {
const mockPush = vi.fn()
const mockAppItems = [
{ id: '1', name: 'App 1' },
{ id: '2', name: 'App 2' },
] as unknown as AppDetailResponse[]
const mockCurApp = mockAppItems[0]!
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
} as unknown as ReturnType<typeof useRouter>)
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceEditor: true,
} as unknown as ReturnType<typeof useAppContext>)
})
describe('Rendering', () => {
it('should render current app name', () => {
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
expect(screen.getByText('App 1'))!.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should open menu and show app items', async () => {
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
const button = screen.getByRole('button', { name: /App 1/i })
await act(async () => {
fireEvent.click(button)
})
expect(screen.getByText('App 2'))!.toBeInTheDocument()
})
it('should navigate to configuration when an app is clicked and user is editor', async () => {
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
const button = screen.getByRole('button', { name: /App 1/i })
await act(async () => {
fireEvent.click(button)
})
const app2Item = screen.getByText('App 2')
await act(async () => {
fireEvent.click(app2Item)
})
expect(mockPush).toHaveBeenCalledWith('/app/2/configuration')
})
it('should navigate to overview when an app is clicked and user is not editor', async () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceEditor: false,
} as unknown as ReturnType<typeof useAppContext>)
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
const button = screen.getByRole('button', { name: /App 1/i })
await act(async () => {
fireEvent.click(button)
})
const app2Item = screen.getByText('App 2')
await act(async () => {
fireEvent.click(app2Item)
})
expect(mockPush).toHaveBeenCalledWith('/app/2/overview')
})
})
describe('New App Dialog', () => {
it('should show "New App" button for editor and open dialog', async () => {
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
const button = screen.getByRole('button', { name: /App 1/i })
await act(async () => {
fireEvent.click(button)
})
const newAppBtn = screen.getByText('common.menus.newApp')
await act(async () => {
fireEvent.click(newAppBtn)
})
expect(screen.getByTestId('create-app-dialog'))!.toBeInTheDocument()
})
it('should not show "New App" button for non-editor', async () => {
vi.mocked(useAppContext).mockReturnValue({
isCurrentWorkspaceEditor: false,
} as unknown as ReturnType<typeof useAppContext>)
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
const button = screen.getByRole('button', { name: /App 1/i })
await act(async () => {
fireEvent.click(button)
})
expect(screen.queryByText('common.menus.newApp')).not.toBeInTheDocument()
})
it('should close dialog when onClose is called', async () => {
render(<AppSelector appItems={mockAppItems} curApp={mockCurApp} />)
const button = screen.getByRole('button', { name: /App 1/i })
await act(async () => {
fireEvent.click(button)
})
const newAppBtn = screen.getByText('common.menus.newApp')
await act(async () => {
fireEvent.click(newAppBtn)
})
const closeBtn = screen.getByText('Close')
await act(async () => {
fireEvent.click(closeBtn)
})
expect(screen.queryByTestId('create-app-dialog')).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render nothing in menu if appItems is empty', async () => {
render(<AppSelector appItems={[]} curApp={mockCurApp} />)
const button = screen.getByRole('button', { name: /App 1/i })
await act(async () => {
fireEvent.click(button)
})
expect(screen.queryByText('App 2')).not.toBeInTheDocument()
// "New App" should still be there if editor
// "New App" should still be there if editor
expect(screen.getByText('common.menus.newApp'))!.toBeInTheDocument()
})
})
})

View File

@ -1,117 +0,0 @@
'use client'
import type { AppDetailResponse } from '@/models/app'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { ChevronDownIcon, PlusIcon } from '@heroicons/react/24/solid'
import { noop } from 'es-toolkit/function'
import { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateAppDialog from '@/app/components/app/create-app-dialog'
import AppIcon from '@/app/components/base/app-icon'
import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
import Indicator from '../indicator'
type IAppSelectorProps = {
appItems: AppDetailResponse[]
curApp: AppDetailResponse
}
export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
const router = useRouter()
const { isCurrentWorkspaceEditor } = useAppContext()
const [showNewAppDialog, setShowNewAppDialog] = useState(false)
const { t } = useTranslation()
const itemClassName = `
flex items-center w-full h-10 px-3 text-gray-700 text-[14px]
rounded-lg font-normal hover:bg-gray-100 cursor-pointer
`
return (
<div className="">
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton
className="
inline-flex h-7 w-full items-center justify-center
rounded-[10px] pr-2.5 pl-2 text-[14px] font-semibold
text-[#1C64F2] hover:bg-[#EBF5FF]
"
>
{curApp?.name}
<ChevronDownIcon
className="ml-1 h-3 w-3"
aria-hidden="true"
/>
</MenuButton>
</div>
<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
className="
absolute right-0 -left-11 mt-1.5 w-60 max-w-80
origin-top-right divide-y divide-gray-100 rounded-lg bg-white
shadow-lg
"
>
{!!appItems.length && (
<div className="overflow-auto px-1 py-1" style={{ maxHeight: '50vh' }}>
{
appItems.map((app: AppDetailResponse) => (
<MenuItem key={app.id}>
<div
className={itemClassName}
onClick={() =>
router.push(`/app/${app.id}/${isCurrentWorkspaceEditor ? 'configuration' : 'overview'}`)}
>
<div className="relative mr-2 h-6 w-6 rounded-md bg-[#D5F5F6]">
<AppIcon size="tiny" />
<div className="absolute -right-0.5 -bottom-0.5 flex h-2.5 w-2.5 items-center justify-center rounded-sm bg-white">
<Indicator />
</div>
</div>
{app.name}
</div>
</MenuItem>
))
}
</div>
)}
{isCurrentWorkspaceEditor && (
<MenuItem>
<div className="p-1" onClick={() => setShowNewAppDialog(true)}>
<div
className="flex h-12 cursor-pointer items-center rounded-lg hover:bg-gray-100"
>
<div
className="
mr-2 ml-4 flex
h-6 w-6 items-center justify-center rounded-md border-[0.5px]
border-dashed border-gray-200 bg-gray-100
"
>
<PlusIcon className="h-4 w-4 text-gray-500" />
</div>
<div className="text-[14px] font-normal text-gray-700">{t('menus.newApp', { ns: 'common' })}</div>
</div>
</div>
</MenuItem>
)}
</MenuItems>
</Transition>
</Menu>
<CreateAppDialog
show={showNewAppDialog}
onClose={() => setShowNewAppDialog(false)}
onSuccess={noop}
/>
</div>
)
}

View File

@ -7,6 +7,7 @@ import {
screen,
waitFor,
} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { use } from 'react'
import { vi } from 'vitest'
@ -291,45 +292,24 @@ describe('Nav Component', () => {
})
it('should show sub-menu and call onCreate with types when isApp is true', async () => {
render(<Nav {...defaultProps} curNav={curNav} isApp />)
const selectorButton = screen.getByRole('button', { name: /Item 1/i })
await act(async () => {
fireEvent.click(selectorButton)
})
const openCreateMenu = async () => {
const createButton = await screen.findByText('Create New')
await act(async () => {
fireEvent.click(createButton)
})
return screen.findByText(/app\.newApp\.startFromBlank/i)
const user = userEvent.setup()
const clickCreateBranch = async (optionName: RegExp) => {
const { unmount } = render(<Nav {...defaultProps} curNav={curNav} isApp />)
await user.click(screen.getByRole('button', { name: /Item 1/i }))
const createButton = await screen.findByRole('menuitem', { name: /Create New/i })
await user.hover(createButton)
fireEvent.click(await screen.findByRole('menuitem', { name: optionName }))
unmount()
}
await openCreateMenu()
const blankOption = await screen.findByText(
/app\.newApp\.startFromBlank/i,
)
await act(async () => {
fireEvent.click(blankOption)
})
expect(mockOnCreate).toHaveBeenCalledWith('blank')
await clickCreateBranch(/app\.newApp\.startFromBlank/i)
await clickCreateBranch(/app\.newApp\.startFromTemplate/i)
await clickCreateBranch(/app\.importDSL/i)
await openCreateMenu()
const templateOption = await screen.findByText(
/app\.newApp\.startFromTemplate/i,
)
await act(async () => {
fireEvent.click(templateOption)
})
expect(mockOnCreate).toHaveBeenCalledWith('template')
await openCreateMenu()
const dslOption = await screen.findByText(/app\.importDSL/i)
await act(async () => {
fireEvent.click(dslOption)
})
expect(mockOnCreate).toHaveBeenCalledWith('dsl')
expect(mockOnCreate).toHaveBeenNthCalledWith(1, 'blank')
expect(mockOnCreate).toHaveBeenNthCalledWith(2, 'template')
expect(mockOnCreate).toHaveBeenNthCalledWith(3, 'dsl')
expect(mockOnCreate).toHaveBeenCalledTimes(3)
})
it('should not show create button if NOT an editor', async () => {

View File

@ -1,6 +1,7 @@
import type { INavSelectorProps, NavItem } from '../index'
import type { AppContextValue } from '@/context/app-context'
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
@ -198,6 +199,7 @@ describe('NavSelector Component', () => {
})
it('should show extended create menu in app mode', async () => {
const user = userEvent.setup()
render(<NavSelector {...defaultProps} isApp />)
const button = screen.getByRole('button')
await act(async () => {
@ -205,10 +207,10 @@ describe('NavSelector Component', () => {
})
const openCreateMenu = async () => {
const createBtn = screen.getByText('Create New')
await act(async () => {
fireEvent.click(createBtn)
})
if (!screen.queryByRole('menuitem', { name: /Create New/i }))
await user.click(screen.getByRole('button', { name: /Item 1/i }))
const createBtn = await screen.findByRole('menuitem', { name: /Create New/i })
await user.hover(createBtn)
return screen.findByText(/app\.newApp\.startFromBlank/i)
}
@ -235,16 +237,15 @@ describe('NavSelector Component', () => {
})
it('should open extended create menu on hover in app mode', async () => {
const user = userEvent.setup()
render(<NavSelector {...defaultProps} isApp />)
const button = screen.getByRole('button')
await act(async () => {
fireEvent.click(button)
})
const createBtn = screen.getByText('Create New')
await act(async () => {
fireEvent.mouseEnter(createBtn)
})
const createBtn = await screen.findByRole('menuitem', { name: /Create New/i })
await user.hover(createBtn)
expect(await screen.findByText(/app\.newApp\.startFromBlank/i))!.toBeInTheDocument()
})

View File

@ -1,14 +1,21 @@
'use client'
import type { AppIconType, AppModeEnum } from '@/types/app'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
RiAddLine,
RiArrowDownSLine,
RiArrowRightSLine,
} from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import { useCallback, useState } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AppTypeIcon } from '@/app/components/app/type-selector'
@ -53,57 +60,54 @@ const AppCreateMenu = ({
importDSLText,
onCreate,
}: AppCreateMenuProps) => {
const [open, setOpen] = useState(false)
const handleCreate = (state: string) => {
setOpen(false)
onCreate(state)
}
return (
<div className="relative h-full w-full" onMouseLeave={() => setOpen(false)}>
<button
type="button"
className="w-full p-1 text-left"
onClick={() => setOpen(value => !value)}
onMouseEnter={() => setOpen(true)}
>
<div className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover',
open && 'bg-state-base-hover!',
)}
<DropdownMenuSub>
<div className="p-1">
<DropdownMenuSubTrigger
className="h-9 gap-2 px-3 py-[6px]"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-background-default">
<RiAddLine className="h-4 w-4 text-text-primary" />
<span className="i-ri-add-line h-4 w-4 text-text-primary" />
</div>
<div className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</div>
<RiArrowRightSLine className="h-3.5 w-3.5 shrink-0 text-text-primary" />
<span className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</span>
</DropdownMenuSubTrigger>
</div>
<DropdownMenuSubContent
placement="right-start"
sideOffset={4}
popupClassName="min-w-[200px] bg-components-panel-bg-blur p-0"
>
<div className="p-1">
<DropdownMenuItem
className="h-9 px-3 py-[6px] font-normal text-text-secondary"
onClick={() => handleCreate('blank')}
>
<FilePlus01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{startFromBlankText}
</DropdownMenuItem>
<DropdownMenuItem
className="h-9 px-3 py-[6px] font-normal text-text-secondary"
onClick={() => handleCreate('template')}
>
<FilePlus02 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{startFromTemplateText}
</DropdownMenuItem>
</div>
</button>
{open && (
<div
className="absolute top-[3px] right-[-198px] z-10 min-w-[200px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg"
onMouseEnter={() => setOpen(true)}
>
<div className="p-1">
<button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('blank')}>
<FilePlus01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{startFromBlankText}
</button>
<button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('template')}>
<FilePlus02 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{startFromTemplateText}
</button>
</div>
<div className="border-t border-divider-regular p-1">
<button type="button" className={cn('flex w-full cursor-pointer items-center rounded-lg px-3 py-[6px] text-left font-normal text-text-secondary hover:bg-state-base-hover')} onClick={() => handleCreate('dsl')}>
<FileArrow01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{importDSLText}
</button>
</div>
<div className="border-t border-divider-regular p-1">
<DropdownMenuItem
className="h-9 px-3 py-[6px] font-normal text-text-secondary"
onClick={() => handleCreate('dsl')}
>
<FileArrow01 className="mr-2 h-4 w-4 shrink-0 text-text-secondary" />
{importDSLText}
</DropdownMenuItem>
</div>
)}
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
@ -123,94 +127,86 @@ const NavSelector = ({ curNav, navigationItems, createText, isApp, onCreate, onL
}, 50), [])
return (
<Menu as="div" className="relative">
{({ open }) => (
<>
<MenuButton className={cn(
'hover:hover:bg-components-main-nav-nav-button-bg-active-hover group inline-flex h-7 w-full items-center justify-center rounded-[10px] pr-2.5 pl-2 text-[14px] font-semibold text-components-main-nav-nav-button-text-active',
open && 'bg-components-main-nav-nav-button-bg-active',
)}
>
<div className="max-w-[157px] truncate" title={curNav?.name}>{curNav?.name}</div>
<RiArrowDownSLine
className={cn('ml-1 h-3 w-3 shrink-0 opacity-50 group-hover:opacity-100', open && 'opacity-100!')}
aria-hidden="true"
/>
</MenuButton>
<MenuItems
className="
absolute right-0 -left-11 mt-1.5 w-60 max-w-80
origin-top-right divide-y divide-divider-regular rounded-lg bg-components-panel-bg-blur
shadow-lg outline-hidden
"
>
<div className="overflow-auto px-1 py-1" style={{ maxHeight: '50vh' }} onScroll={handleScroll}>
{
navigationItems.map(nav => (
<MenuItem key={nav.id}>
<div
className="flex w-full cursor-pointer items-center truncate rounded-lg px-3 py-[6px] text-[14px] font-normal text-text-secondary hover:bg-state-base-hover"
onClick={() => {
if (curNav?.id === nav.id)
return
setAppDetail()
router.push(nav.link)
}}
title={nav.name}
>
<div className="relative mr-2 h-6 w-6 rounded-md">
<AppIcon
size="tiny"
iconType={nav.icon_type}
icon={nav.icon}
background={nav.icon_background}
imageUrl={nav.icon_url}
/>
{!!nav.mode && (
<AppTypeIcon type={nav.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 shadow-sm" className="h-2.5 w-2.5" />
)}
</div>
<div className="truncate">
{nav.name}
</div>
</div>
</MenuItem>
))
}
{isLoadingMore && (
<div className="flex justify-center py-2">
<Loading />
</div>
)}
</div>
{!isApp && isCurrentWorkspaceEditor && (
<MenuItem as="div" className="w-full p-1">
<div
onClick={() => onCreate('')}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg px-3 py-[6px] hover:bg-state-base-hover',
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
'hover:hover:bg-components-main-nav-nav-button-bg-active-hover group inline-flex h-7 items-center justify-center rounded-[10px] pr-2.5 pl-2 text-[14px] font-semibold text-components-main-nav-nav-button-text-active outline-hidden',
'focus-visible:bg-components-main-nav-nav-button-bg-active focus-visible:ring-1 focus-visible:ring-components-input-border-hover data-popup-open:bg-components-main-nav-nav-button-bg-active',
)}
>
<div className="max-w-[157px] truncate" title={curNav?.name}>{curNav?.name}</div>
<RiArrowDownSLine
className="ml-1 h-3 w-3 shrink-0 opacity-50 group-hover:opacity-100 group-data-popup-open:opacity-100"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={6}
popupClassName="w-60 max-w-80 divide-y divide-divider-regular bg-components-panel-bg-blur p-0"
>
<div className="max-h-[50vh] overflow-auto px-1 py-1" onScroll={handleScroll}>
{
navigationItems.map(nav => (
<DropdownMenuItem
key={nav.id}
className="h-auto truncate px-3 py-[6px] text-[14px] font-normal text-text-secondary"
onClick={() => {
if (curNav?.id === nav.id)
return
setAppDetail()
router.push(nav.link)
}}
title={nav.name}
>
<div className="relative mr-2 h-6 w-6 shrink-0 rounded-md">
<AppIcon
size="tiny"
iconType={nav.icon_type}
icon={nav.icon}
background={nav.icon_background}
imageUrl={nav.icon_url}
/>
{!!nav.mode && (
<AppTypeIcon type={nav.mode} wrapperClassName="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 shadow-sm" className="h-2.5 w-2.5" />
)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-background-default">
<RiAddLine className="h-4 w-4 text-text-primary" />
</div>
<div className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</div>
</div>
</MenuItem>
)}
{isApp && isCurrentWorkspaceEditor && (
<AppCreateMenu
createText={createText}
startFromBlankText={t('newApp.startFromBlank', { ns: 'app' })}
startFromTemplateText={t('newApp.startFromTemplate', { ns: 'app' })}
importDSLText={t('importDSL', { ns: 'app' })}
onCreate={onCreate}
/>
)}
</MenuItems>
</>
)}
</Menu>
<div className="min-w-0 truncate">
{nav.name}
</div>
</DropdownMenuItem>
))
}
{isLoadingMore && (
<div className="flex justify-center py-2">
<Loading />
</div>
)}
</div>
{!isApp && isCurrentWorkspaceEditor && (
<div className="p-1">
<DropdownMenuItem
className="h-9 gap-2 px-3 py-[6px]"
onClick={() => onCreate('')}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-background-default">
<RiAddLine className="h-4 w-4 text-text-primary" />
</div>
<div className="grow text-left text-[14px] font-normal text-text-secondary">{createText}</div>
</DropdownMenuItem>
</div>
)}
{isApp && isCurrentWorkspaceEditor && (
<AppCreateMenu
createText={createText}
startFromBlankText={t('newApp.startFromBlank', { ns: 'app' })}
startFromTemplateText={t('newApp.startFromTemplate', { ns: 'app' })}
importDSLText={t('importDSL', { ns: 'app' })}
onCreate={onCreate}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -2,6 +2,7 @@ import type { ComponentProps } from 'react'
import type { CredentialFormSchema, FormOption } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { AppSelectorValue } from '@/app/components/plugins/plugin-detail-panel/app-selector'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
@ -207,7 +208,8 @@ describe('FormInputItem branches', () => {
})
})
it('should render static multi-select values and update selected labels', () => {
it('should render static multi-select values and update selected labels', async () => {
const user = userEvent.setup()
const { onChange } = renderFormInputItem({
schema: createSchema({
multiple: true,
@ -226,8 +228,8 @@ describe('FormInputItem branches', () => {
})
expect(screen.getByText('alpha')).toBeInTheDocument()
fireEvent.click(screen.getByText('alpha').closest('button') as HTMLButtonElement)
fireEvent.click(screen.getByText('beta'))
await user.click(screen.getByRole('combobox', { name: 'alpha' }))
await user.click(await screen.findByRole('option', { name: 'beta' }))
expect(onChange).toHaveBeenCalledWith({
field: {

View File

@ -1,4 +1,5 @@
import { fireEvent, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import {
JsonEditorField,
@ -18,7 +19,7 @@ describe('form-input-item sections', () => {
/>,
)
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Options' })).toHaveTextContent('Loading')
})
it('should render the shared json editor section', () => {
@ -33,7 +34,8 @@ describe('form-input-item sections', () => {
expect(screen.getByText('JSON')).toBeInTheDocument()
})
it('should render placeholder, icons, and select multi-select options', () => {
it('should render placeholder, icons, and select multi-select options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
renderWorkflowComponent(
@ -51,8 +53,8 @@ describe('form-input-item sections', () => {
)
expect(screen.getByText('Choose options')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('Alpha'))
await user.click(screen.getByRole('combobox', { name: 'Choose options' }))
await user.click(await screen.findByRole('option', { name: 'Alpha' }))
expect(document.querySelector('img[src="/alpha.svg"]')).toBeInTheDocument()
expect(onChange).toHaveBeenCalled()

View File

@ -2,10 +2,16 @@
import type { FC, ReactElement } from 'react'
import type { SelectItem } from './form-input-item.helpers'
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
import {
SelectItem as DifySelectItem,
Select,
SelectContent,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { RiLoader4Line } from '@remixicon/react'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@ -20,20 +26,7 @@ type MultiSelectFieldProps = {
}
const LoadingIndicator = () => (
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />
)
const ToggleIndicator = () => (
<ChevronDownIcon
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
aria-hidden="true"
/>
)
const SelectedMark = () => (
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent">
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
<RiLoader4Line className="mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-secondary motion-reduce:animate-none" aria-hidden="true" />
)
export const MultiSelectField: FC<MultiSelectFieldProps> = ({
@ -56,48 +49,44 @@ export const MultiSelectField: FC<MultiSelectFieldProps> = ({
const renderLabel = () => {
if (isLoading)
return 'Loading...'
return 'Loading'
return selectedLabel || placeholder || 'Select options'
}
return (
<Listbox multiple value={value} onChange={onChange} disabled={disabled}>
<div className="group/simple-select relative h-8 grow">
<ListboxButton className="flex h-full w-full cursor-pointer items-center rounded-lg border-0 bg-components-input-bg-normal pr-10 pl-3 group-hover/simple-select:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt focus-visible:outline-hidden sm:text-sm sm:leading-6">
<span className={textClassName}>
<Select multiple value={value} onValueChange={onChange} disabled={disabled || isLoading}>
<div className="grow">
<SelectTrigger aria-label={placeholder || selectedLabel || 'Options'}>
<span className={cn('flex min-w-0 items-center', textClassName)}>
{isLoading && <LoadingIndicator />}
{renderLabel()}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
{isLoading ? <LoadingIndicator /> : <ToggleIndicator />}
</span>
</ListboxButton>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-xs focus:outline-hidden sm:text-sm">
</SelectTrigger>
<SelectContent
popupClassName="w-(--anchor-width) bg-components-panel-bg-blur backdrop-blur-xs"
listClassName="max-h-60"
>
{items.map(item => (
<ListboxOption
<DifySelectItem
key={item.value}
value={item.value}
className={({ focus }) =>
cn('relative cursor-pointer rounded-lg py-2 pr-9 pl-3 text-text-secondary select-none hover:bg-state-base-hover', focus && 'bg-state-base-hover')}
className="h-auto py-2 pr-9 pl-3"
>
{({ selected }) => (
<>
<div className="flex items-center">
{item.icon && (
<img src={item.icon} alt="" className="mr-2 h-4 w-4" />
)}
<span className={cn('block truncate', selected && 'font-normal')}>
{item.name}
</span>
</div>
{selected && <SelectedMark />}
</>
)}
</ListboxOption>
<div className="flex min-w-0 items-center">
{item.icon && (
<img src={item.icon} alt="" width={16} height={16} className="mr-2 h-4 w-4 shrink-0" />
)}
<SelectItemText>
{item.name}
</SelectItemText>
</div>
<SelectItemIndicator />
</DifySelectItem>
))}
</ListboxOptions>
</SelectContent>
</div>
</Listbox>
</Select>
)
}