From 2c9e30426d9049305dbc661e148bf54fb8f49066 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 14:49:26 +0800 Subject: [PATCH] refactor(web): migrate headless-ui components to dify-ui (#35962) --- web/__tests__/header/nav-flow.test.tsx | 27 +- web/app/account/(commonLayout)/avatar.tsx | 119 ++++---- .../header-opts/__tests__/index.spec.tsx | 152 +++++++---- .../app/annotation/header-opts/index.tsx | 131 ++++----- .../__tests__/access-control-dialog.spec.tsx | 3 +- .../__tests__/access-control.spec.tsx | 6 +- .../access-control-dialog.tsx | 49 +--- .../app/app-access-control/index.tsx | 2 +- .../__tests__/param-config-content.spec.tsx | 48 +--- .../text-to-speech/param-config-content.tsx | 182 ++++--------- .../credential-selector/index.tsx | 142 ++++------ .../operation/transfer-ownership.tsx | 64 ++--- .../__tests__/priority-selector.spec.tsx | 29 -- .../provider-added-card/priority-selector.tsx | 77 ------ .../app-selector/__tests__/index.spec.tsx | 172 ------------ .../components/header/app-selector/index.tsx | 117 -------- .../header/nav/__tests__/index.spec.tsx | 52 ++-- .../nav/nav-selector/__tests__/index.spec.tsx | 17 +- .../header/nav/nav-selector/index.tsx | 256 +++++++++--------- .../form-input-item.branches.spec.tsx | 8 +- .../form-input-item.sections.spec.tsx | 12 +- .../components/form-input-item.sections.tsx | 81 +++--- 22 files changed, 583 insertions(+), 1163 deletions(-) delete mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/priority-selector.spec.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx delete mode 100644 web/app/components/header/app-selector/__tests__/index.spec.tsx delete mode 100644 web/app/components/header/app-selector/index.tsx diff --git a/web/__tests__/header/nav-flow.test.tsx b/web/__tests__/header/nav-flow.test.tsx index 667f1e36b7..58c95f0a01 100644 --- a/web/__tests__/header/nav-flow.test.tsx +++ b/web/__tests__/header/nav-flow.test.tsx @@ -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 () => { diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index ccae182c9a..3fefb8a319 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -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 ( - - { - ({ open }) => ( - <> -
- - - + + + + + +
+
+
+
+ {userProfile.name} + {isEducationAccount && ( + + + EDU + + )} +
+
{userProfile.email}
- - - -
-
-
-
- {userProfile.name} - {isEducationAccount && ( - - - EDU - - )} -
-
{userProfile.email}
-
- -
-
-
- -
handleLogout()}> -
- -
{t('userProfile.logout', { ns: 'common' })}
-
-
-
-
-
- - ) - } -
+ + + +
+ + + {t('userProfile.logout', { ns: 'common' })} + +
+ + ) } diff --git a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx index 944a8563eb..5e7b2dc1d0 100644 --- a/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/__tests__/index.spec.tsx @@ -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 | undefined -const mockCSVDownloader = vi.fn(({ children, ...props }) => { - lastCSVDownloaderProps = props - return ( -
- {children} -
- ) -}) +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) = const expandExportMenu = async (user: ReturnType) => { 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, - 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[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((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) diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index fc27524c71..6814c3692c 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -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 = ({ list, onClose, @@ -63,88 +74,62 @@ const OperationsMenu: FC = ({ }) => { const { t } = useTranslation() const locale = useLocale() - const { CSVDownloader, Type } = useCSVDownloader() const annotationUnavailable = list.length === 0 return ( -
- - - - - {t('table.header.bulkExport', { ns: 'appAnnotation' })} - - - + {t('table.header.bulkImport', { ns: 'appAnnotation' })} + + + + + {t('table.header.bulkExport', { ns: 'appAnnotation' })} + + - { + onClose() + downloadAnnotationCsv(list, locale) + }} > - [item.question, item.answer]), - ]} - > - - - - - - - -
+ + {t('table.header.clearAll', { ns: 'appAnnotation' })} + + ) } @@ -204,7 +189,7 @@ const HeaderOptions: FC = ({
{t('table.header.addAnnotation', { ns: 'appAnnotation' })}
- + = ({ { , ) - 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) diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 21dd8c5fc2..4aaea1670f 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -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(
Dialog Content
, ) - const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement + const closeButton = screen.getByRole('button', { name: 'Close' }) fireEvent.click(closeButton) await waitFor(() => { diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx index bbf5329c9d..611c6f1c92 100644 --- a/web/app/components/app/app-access-control/access-control-dialog.tsx +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -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 ( - - null}> - -
- - -
- - -
close()} className="absolute top-5 right-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center"> - -
- {children} -
-
-
-
-
+ !open && close()}> + + + {children} + + ) } diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index cff670e10f..593664c918 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -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' diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx index b4d5beefa6..27a6cd96d0 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx @@ -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', () => { diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index f7c3b738a9..24670fa748 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -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 = ({ <>
{t('voice.voiceSettings.title', { ns: 'appDebug' })}
-
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onClose() - } - }} > - -
+ +
@@ -100,129 +100,63 @@ const VoiceParamConfig = ({ ))}
- { +
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
- { + onValueChange={(nextValue) => { + if (!nextValue) + return handleChange({ - voice: String(value.value), + voice: nextValue, }) }} > -
- - - {voiceItem?.name ?? localVoicePlaceholder} - - - - - - - - {voiceItems?.map((item: SelectOption) => ( - - {({ /* active, */ selected }) => ( - <> - {item.name} - {(selected || item.value === text2speech?.voice) && ( - - - )} - - )} - - ))} - - +
+ + {voiceItem?.name ?? localVoicePlaceholder} + + + {voiceItems?.map((item: SelectOption) => ( + + + {item.name} + + + + ))} +
- + {languageItem?.example && (
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 ( - - { - ({ open }) => ( - <> - nextValue && onSelect(nextValue)}> + + + + + {currentDisplayName} + + + + + {items.map((item) => { + const displayName = getDisplayName(item) + return ( + -
- {currentDisplayName} -
-
- - - -
- { - items.map((item) => { - const displayName = getDisplayName(item) - return ( - -
onSelect(item.credentialId)} - data-testid={`notion-credential-item-${item.credentialId}`} - > - -
- {displayName} -
- {/* // ?Cannot get page length with new auth system */} - {/*
- {item.pages.length} {t('common.dataSource.notion.selector.pageSelected')} -
*/} -
-
- ) - }) - } -
-
-
- - ) - } -
+ + {displayName} + + + + ) + })} + + ) } -export default React.memo(CredentialSelector) +export default CredentialSelector diff --git a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx index 97a4a5b2f2..e46989643e 100644 --- a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx +++ b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx @@ -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 ( - - { - ({ open }) => ( - <> - - {t('members.owner', { ns: 'common' })} - - - - -
- -
-
{t('members.transferOwnership', { ns: 'common' })}
-
-
-
-
-
- - ) - } -
+ + + {t('members.owner', { ns: 'common' })} + + + + + {t('members.transferOwnership', { ns: 'common' })} + + + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/priority-selector.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/priority-selector.spec.tsx deleted file mode 100644 index d122bf921b..0000000000 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/priority-selector.spec.tsx +++ /dev/null @@ -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() - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should call onSelect when option clicked', () => { - render() - 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() - fireEvent.click(screen.getByRole('button')) - expect(screen.getByText('common.modelProvider.card.priorityUse')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx deleted file mode 100644 index a74c400035..0000000000 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx +++ /dev/null @@ -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 = ({ - 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 ( - - - { - ({ open }) => ( - - ) - } - - - -
-
{t('modelProvider.card.priorityUse', { ns: 'common' })}
- { - options.map(option => ( - -
onSelect(option.key)} - > -
{option.text}
- {value === option.key && } -
-
- )) - } -
-
-
-
- ) -} - -export default Selector diff --git a/web/app/components/header/app-selector/__tests__/index.spec.tsx b/web/app/components/header/app-selector/__tests__/index.spec.tsx deleted file mode 100644 index 2d255c006e..0000000000 --- a/web/app/components/header/app-selector/__tests__/index.spec.tsx +++ /dev/null @@ -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 - ? ( -
- -
- ) - : 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) - vi.mocked(useAppContext).mockReturnValue({ - isCurrentWorkspaceEditor: true, - } as unknown as ReturnType) - }) - - describe('Rendering', () => { - it('should render current app name', () => { - render() - expect(screen.getByText('App 1'))!.toBeInTheDocument() - }) - }) - - describe('Interactions', () => { - it('should open menu and show app items', async () => { - render() - - 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() - - 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) - - render() - - 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() - - 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) - - render() - - 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() - - 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() - - 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() - }) - }) -}) diff --git a/web/app/components/header/app-selector/index.tsx b/web/app/components/header/app-selector/index.tsx deleted file mode 100644 index 52e60de2b4..0000000000 --- a/web/app/components/header/app-selector/index.tsx +++ /dev/null @@ -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 ( -
- -
- - {curApp?.name} - -
- - - {!!appItems.length && ( -
- { - appItems.map((app: AppDetailResponse) => ( - -
- router.push(`/app/${app.id}/${isCurrentWorkspaceEditor ? 'configuration' : 'overview'}`)} - > -
- -
- -
-
- {app.name} -
-
- )) - } -
- )} - {isCurrentWorkspaceEditor && ( - -
setShowNewAppDialog(true)}> -
-
- -
-
{t('menus.newApp', { ns: 'common' })}
-
-
-
- )} -
-
-
- setShowNewAppDialog(false)} - onSuccess={noop} - /> -
- ) -} diff --git a/web/app/components/header/nav/__tests__/index.spec.tsx b/web/app/components/header/nav/__tests__/index.spec.tsx index f4a1399638..6f9b448981 100644 --- a/web/app/components/header/nav/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/__tests__/index.spec.tsx @@ -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(