mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 21:28:25 +08:00
refactor(web): migrate headless-ui components to dify-ui (#35962)
This commit is contained in:
parent
2bb1f0906b
commit
2c9e30426d
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user