From a83118c0f4377bcd49929f434178ae6a95ea7a0f Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:45:22 +0800 Subject: [PATCH] refactor(web): compose tab header with dify-ui tabs (#37280) --- eslint-suppressions.json | 24 --- .../dify-ui/src/tabs/__tests__/index.spec.tsx | 5 + packages/dify-ui/src/tabs/index.stories.tsx | 12 +- packages/dify-ui/src/tabs/index.tsx | 4 +- .../share/text-generation-index-flow.test.tsx | 8 +- .../components/app-sidebar/nav-link/index.tsx | 4 +- .../base/tab-header/__tests__/index.spec.tsx | 114 -------------- .../base/tab-header/index.stories.tsx | 66 -------- web/app/components/base/tab-header/index.tsx | 60 ------- .../explore/try-app/__tests__/index.spec.tsx | 16 +- .../explore/try-app/__tests__/tab.spec.tsx | 54 ------- web/app/components/explore/try-app/index.tsx | 47 ++++-- web/app/components/explore/try-app/tab.tsx | 43 ----- web/app/components/explore/try-app/types.ts | 6 + .../text-generation-sidebar.spec.tsx | 3 +- .../text-generation-sidebar.tsx | 76 ++++----- .../workflow-panel/__tests__/index.spec.tsx | 18 +-- .../_base/components/workflow-panel/index.tsx | 148 +++++++++--------- .../workflow-panel/last-run/use-last-run.ts | 2 +- .../_base/components/workflow-panel/tab.tsx | 35 ----- .../_base/components/workflow-panel/types.ts | 7 + 21 files changed, 190 insertions(+), 562 deletions(-) delete mode 100644 web/app/components/base/tab-header/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/tab-header/index.stories.tsx delete mode 100644 web/app/components/base/tab-header/index.tsx delete mode 100644 web/app/components/explore/try-app/__tests__/tab.spec.tsx delete mode 100644 web/app/components/explore/try-app/tab.tsx create mode 100644 web/app/components/explore/try-app/types.ts delete mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/types.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index fd90ebbff53..5af212a016d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -255,11 +255,6 @@ "count": 1 } }, - "web/app/components/app-sidebar/nav-link/index.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 3 - } - }, "web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -2366,14 +2361,6 @@ "count": 2 } }, - "web/app/components/explore/try-app/tab.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/goto-anything/actions/commands/command-bus.ts": { "ts/no-explicit-any": { "count": 2 @@ -3716,17 +3703,6 @@ "count": 7 } }, - "web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts": { "no-restricted-imports": { "count": 1 diff --git a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx index 6673e35bf5c..278cbfbbad4 100644 --- a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx @@ -39,10 +39,15 @@ describe('Tabs wrappers', () => { await expect.element(screen.getByRole('tablist')).toHaveClass( 'flex', + 'gap-4', ) await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass( 'touch-manipulation', 'focus-visible:outline-hidden', + 'border-b-2', + 'border-transparent', + 'data-active:border-components-tab-active', + 'data-active:text-text-primary', ) }) diff --git a/packages/dify-ui/src/tabs/index.stories.tsx b/packages/dify-ui/src/tabs/index.stories.tsx index dd1e79a1cee..11087a7411c 100644 --- a/packages/dify-ui/src/tabs/index.stories.tsx +++ b/packages/dify-ui/src/tabs/index.stories.tsx @@ -26,17 +26,11 @@ type Story = StoryObj export const Basic: Story = { render: () => ( - - + + Overview - + Activity diff --git a/packages/dify-ui/src/tabs/index.tsx b/packages/dify-ui/src/tabs/index.tsx index e93358b8e2c..6acf50ef83b 100644 --- a/packages/dify-ui/src/tabs/index.tsx +++ b/packages/dify-ui/src/tabs/index.tsx @@ -18,7 +18,7 @@ export function TabsList({ }: TabsListProps) { return ( ) @@ -34,7 +34,7 @@ export function TabsTab({ }: TabsTabProps) { return ( ) diff --git a/web/__tests__/share/text-generation-index-flow.test.tsx b/web/__tests__/share/text-generation-index-flow.test.tsx index 638f774c167..1295a211cb1 100644 --- a/web/__tests__/share/text-generation-index-flow.test.tsx +++ b/web/__tests__/share/text-generation-index-flow.test.tsx @@ -197,13 +197,13 @@ describe('TextGeneration', () => { expect(screen.getByTestId('run-once-input-name')).toHaveTextContent('Gamma') }) - fireEvent.click(screen.getByTestId('tab-header-item-batch')) + fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' })) expect(screen.getByRole('button', { name: 'run-batch' })).toBeInTheDocument() - fireEvent.click(screen.getByTestId('tab-header-item-saved')) + fireEvent.click(screen.getByRole('tab', { name: /^share\.generation\.tabs\.saved/ })) expect(screen.getByTestId('saved-items-mock')).toHaveTextContent('2') - fireEvent.click(screen.getByTestId('tab-header-item-create')) + fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.create' })) expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() }) @@ -220,7 +220,7 @@ describe('TextGeneration', () => { }) expect(screen.getByTestId('result-single')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('tab-header-item-batch')) + fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' })) fireEvent.click(screen.getByRole('button', { name: 'run-batch' })) await waitFor(() => { expect(screen.getByText('idle')).toBeInTheDocument() diff --git a/web/app/components/app-sidebar/nav-link/index.tsx b/web/app/components/app-sidebar/nav-link/index.tsx index a2bdb09f7bd..31c3dd7bc9d 100644 --- a/web/app/components/app-sidebar/nav-link/index.tsx +++ b/web/app/components/app-sidebar/nav-link/index.tsx @@ -46,8 +46,8 @@ const NavLink = ({ const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false) const NavIcon = isActive ? iconMap.selected : iconMap.normal const linkClassName = cn(isActive - ? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold' - : 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1') + ? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only' + : 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3') const renderIcon = () => (
diff --git a/web/app/components/base/tab-header/__tests__/index.spec.tsx b/web/app/components/base/tab-header/__tests__/index.spec.tsx deleted file mode 100644 index ee104151546..00000000000 --- a/web/app/components/base/tab-header/__tests__/index.spec.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' -import TabHeader from '../index' - -describe('TabHeader Component', () => { - const mockItems = [ - { id: 'tab1', name: 'General' }, - { id: 'tab2', name: 'Settings' }, - { id: 'tab3', name: 'Profile', isRight: true }, - { id: 'tab4', name: 'Disabled Tab', disabled: true }, - ] - - it('should render all items with correct names', () => { - render( { }} />) - - expect(screen.getByText('General')).toBeInTheDocument() - expect(screen.getByText('Settings')).toBeInTheDocument() - expect(screen.getByText('Profile')).toBeInTheDocument() - expect(screen.getByText('Disabled Tab')).toBeInTheDocument() - }) - - it('should separate items into left and right containers correctly', () => { - render( { }} />) - - const leftContainer = screen.getByTestId('tab-header-left') - const rightContainer = screen.getByTestId('tab-header-right') - - // Verify children count - expect(leftContainer.children.length).toBe(3) - expect(rightContainer.children.length).toBe(1) - - // Verify specific item placement using within and toContainElement - const profileTab = screen.getByTestId('tab-header-item-tab3') - expect(rightContainer).toContainElement(profileTab) - - const disabledTab = screen.getByTestId('tab-header-item-tab4') - expect(leftContainer).toContainElement(disabledTab) - }) - - it('should apply active styles to the selected tab', () => { - const activeClass = 'custom-active-style' - render( - { }} - />, - ) - - const activeTab = screen.getByTestId('tab-header-item-tab2') - expect(activeTab).toHaveClass('border-components-tab-active') - expect(activeTab).toHaveClass(activeClass) - - const inactiveTab = screen.getByTestId('tab-header-item-tab1') - expect(inactiveTab).toHaveClass('text-text-tertiary') - }) - - it('should call onChange when a non-disabled tab is clicked', async () => { - const user = userEvent.setup() - const handleChange = vi.fn() - render() - - await user.click(screen.getByText('Settings')) - expect(handleChange).toHaveBeenCalledWith('tab2') - }) - - it('should not call onChange when a disabled tab is clicked', async () => { - const user = userEvent.setup() - const handleChange = vi.fn() - render() - - const disabledTab = screen.getByTestId('tab-header-item-tab4') - expect(disabledTab).toHaveClass('cursor-not-allowed') - - await user.click(disabledTab) - expect(handleChange).not.toHaveBeenCalled() - }) - - it('should render icon and extra content when provided', () => { - const itemsWithExtras = [ - { - id: 'extra', - name: 'Extra', - icon: 🚀, - extra: New, - }, - ] - render( { }} />) - - expect(screen.getByTestId('tab-icon')).toBeInTheDocument() - expect(screen.getByTestId('tab-extra')).toBeInTheDocument() - }) - - it('should apply custom class names for items and wrappers', () => { - render( - { }} - />, - ) - - const tabWrap = screen.getByTestId('tab-header-item-tab1') - // We target the inner div for the name class check - const tabText = within(tabWrap).getByText('General') - - expect(tabWrap).toHaveClass('my-wrap-class') - expect(tabText).toHaveClass('my-text-class') - }) -}) diff --git a/web/app/components/base/tab-header/index.stories.tsx b/web/app/components/base/tab-header/index.stories.tsx deleted file mode 100644 index e783b67cd27..00000000000 --- a/web/app/components/base/tab-header/index.stories.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import type { ITabHeaderProps } from '.' -import { useState } from 'react' -import TabHeader from '.' - -const items: ITabHeaderProps['items'] = [ - { id: 'overview', name: 'Overview' }, - { id: 'playground', name: 'Playground' }, - { id: 'changelog', name: 'Changelog', extra: New }, - { id: 'docs', name: 'Docs', isRight: true }, - { id: 'settings', name: 'Settings', isRight: true, disabled: true }, -] - -const TabHeaderDemo = ({ - initialTab = 'overview', -}: { - initialTab?: string -}) => { - const [activeTab, setActiveTab] = useState(initialTab) - - return ( -
-
- Tabs - - active=" - {activeTab} - " - -
- -
- ) -} - -const meta = { - title: 'Base/Navigation/TabHeader', - component: TabHeaderDemo, - parameters: { - layout: 'centered', - docs: { - description: { - component: 'Two-sided header tabs with optional right-aligned actions. Disabled items illustrate read-only states.', - }, - }, - }, - argTypes: { - initialTab: { - control: 'radio', - options: items.map(item => item.id), - }, - }, - args: { - initialTab: 'overview', - }, - tags: ['autodocs'], -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Playground: Story = {} diff --git a/web/app/components/base/tab-header/index.tsx b/web/app/components/base/tab-header/index.tsx deleted file mode 100644 index ebe2350acc3..00000000000 --- a/web/app/components/base/tab-header/index.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client' -import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' - -type Item = { - id: string - name: string - isRight?: boolean - icon?: React.ReactNode - extra?: React.ReactNode - disabled?: boolean -} - -export type ITabHeaderProps = Readonly<{ - items: Item[] - value: string - itemClassName?: string - itemWrapClassName?: string - activeItemClassName?: string - onChange: (value: string) => void -}> - -const TabHeader: FC = ({ - items, - value, - itemClassName, - itemWrapClassName, - activeItemClassName, - onChange, -}) => { - const renderItem = ({ id, name, icon, extra, disabled }: Item) => ( -
!disabled && onChange(id)} - > - {icon || ''} -
{name}
- {extra || ''} -
- ) - return ( -
-
- {items.filter(item => !item.isRight).map(renderItem)} -
-
- {items.filter(item => item.isRight).map(renderItem)} -
-
- ) -} -export default React.memo(TabHeader) diff --git a/web/app/components/explore/try-app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx index 84d6332e67a..52de1356cf5 100644 --- a/web/app/components/explore/try-app/__tests__/index.spec.tsx +++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import TryApp from '../index' -import { TypeEnum } from '../tab' +import { TypeEnum } from '../types' vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() as object @@ -213,8 +213,7 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - const buttons = document.body.querySelectorAll('button') - expect(buttons.length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) }) }) @@ -281,16 +280,11 @@ describe('TryApp (main index.tsx)', () => { ) await waitFor(() => { - const buttons = document.body.querySelectorAll('button') - const closeButton = Array.from(buttons).find(btn => - btn.querySelector('svg') || btn.className.includes('rounded-[10px]'), - ) - expect(closeButton).toBeInTheDocument() - - if (closeButton) - fireEvent.click(closeButton) + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) + expect(mockOnClose).toHaveBeenCalled() }) diff --git a/web/app/components/explore/try-app/__tests__/tab.spec.tsx b/web/app/components/explore/try-app/__tests__/tab.spec.tsx deleted file mode 100644 index 9a7f04b81d8..00000000000 --- a/web/app/components/explore/try-app/__tests__/tab.spec.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import Tab, { TypeEnum } from '../tab' - -vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() as object - return { - ...actual, - IS_CLOUD_EDITION: true, - } -}) - -describe('Tab', () => { - afterEach(() => { - cleanup() - }) - - it('renders tab with TRY value selected', () => { - const mockOnChange = vi.fn() - render() - - expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() - expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() - }) - - it('renders tab with DETAIL value selected', () => { - const mockOnChange = vi.fn() - render() - - expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument() - expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument() - }) - - it('calls onChange when clicking a tab', () => { - const mockOnChange = vi.fn() - render() - - fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail')) - expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL) - }) - - it('calls onChange when clicking Try tab', () => { - const mockOnChange = vi.fn() - render() - - fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try')) - expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY) - }) - - it('exports TypeEnum correctly', () => { - expect(TypeEnum.TRY).toBe('try') - expect(TypeEnum.DETAIL).toBe('detail') - }) -}) diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index 0d7d816fe1e..3a19075cdad 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -4,9 +4,11 @@ import type { FC } from 'react' import type { App as AppType } from '@/models/explore' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' import { IS_CLOUD_EDITION } from '@/config' @@ -15,7 +17,7 @@ import { useGetTryAppInfo } from '@/service/use-try-app' import App from './app' import AppInfo from './app-info' import Preview from './preview' -import Tab, { TypeEnum } from './tab' +import { TypeEnum } from './types' type Props = { appId: string @@ -32,6 +34,7 @@ const TryApp: FC = ({ onClose, onCreate, }) => { + const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app) const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true) @@ -62,25 +65,49 @@ const TryApp: FC = ({
) : ( -
+ setType(selectedValue)} + className="flex h-full flex-col" + >
- + + {IS_CLOUD_EDITION && ( + + {t('tryApp.tabHeader.try', { ns: 'explore' })} + + )} + + {t('tryApp.tabHeader.detail', { ns: 'explore' })} + +
{/* Main content */}
- {activeType === TypeEnum.TRY ? : } + {IS_CLOUD_EDITION && ( + + + + )} + + + = ({ onCreate={onCreate} />
-
+
)} diff --git a/web/app/components/explore/try-app/tab.tsx b/web/app/components/explore/try-app/tab.tsx deleted file mode 100644 index 55d0900fad8..00000000000 --- a/web/app/components/explore/try-app/tab.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client' -import type { FC } from 'react' -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import { IS_CLOUD_EDITION } from '@/config' -import TabHeader from '../../base/tab-header' - -export enum TypeEnum { - TRY = 'try', - DETAIL = 'detail', -} - -type Props = { - value: TypeEnum - onChange: (value: TypeEnum) => void - disableTry?: boolean -} - -const Tab: FC = ({ - value, - onChange, - disableTry, -}) => { - const { t } = useTranslation() - - const tabs = React.useMemo(() => { - return [ - IS_CLOUD_EDITION ? { id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }), disabled: disableTry } : null, - { id: TypeEnum.DETAIL, name: t('tryApp.tabHeader.detail', { ns: 'explore' }) }, - ].filter(item => item !== null) as { id: TypeEnum, name: string }[] - }, [t, disableTry]) - return ( - void} - itemClassName="ml-0 system-md-semibold-uppercase" - itemWrapClassName="pt-2" - activeItemClassName="border-util-colors-blue-brand-blue-brand-500" - /> - ) -} -export default React.memo(Tab) diff --git a/web/app/components/explore/try-app/types.ts b/web/app/components/explore/try-app/types.ts new file mode 100644 index 00000000000..81a50893db5 --- /dev/null +++ b/web/app/components/explore/try-app/types.ts @@ -0,0 +1,6 @@ +export const TypeEnum = { + TRY: 'try', + DETAIL: 'detail', +} as const + +export type TypeEnum = typeof TypeEnum[keyof typeof TypeEnum] diff --git a/web/app/components/share/text-generation/__tests__/text-generation-sidebar.spec.tsx b/web/app/components/share/text-generation/__tests__/text-generation-sidebar.spec.tsx index d9e6c137493..421bf7be3b2 100644 --- a/web/app/components/share/text-generation/__tests__/text-generation-sidebar.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/text-generation-sidebar.spec.tsx @@ -113,6 +113,7 @@ describe('TextGenerationSidebar', () => { expect(screen.getByText('Text Generation')).toBeInTheDocument() expect(screen.getByText('Share description')).toBeInTheDocument() + expect(screen.getByRole('tablist')).toHaveClass('w-full') expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() expect(runOncePropsSpy).toHaveBeenCalledWith(expect.objectContaining({ inputs: { name: 'Alice' }, @@ -134,7 +135,7 @@ describe('TextGenerationSidebar', () => { vars: promptConfig.prompt_variables, isAllFinished: true, })) - expect(screen.queryByTestId('tab-header-item-saved')).not.toBeInTheDocument() + expect(screen.queryByRole('tab', { name: /^share\.generation\.tabs\.saved/ })).not.toBeInTheDocument() }) it('should render saved items and allow switching back to create tab', () => { diff --git a/web/app/components/share/text-generation/text-generation-sidebar.tsx b/web/app/components/share/text-generation/text-generation-sidebar.tsx index e7140812775..7cc7b0da2af 100644 --- a/web/app/components/share/text-generation/text-generation-sidebar.tsx +++ b/web/app/components/share/text-generation/text-generation-sidebar.tsx @@ -5,6 +5,7 @@ import type { PromptConfig, SavedMessage, TextToSpeechConfig } from '@/models/de import type { SiteInfo } from '@/models/share' import type { VisionFile, VisionSettings } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' +import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' import { useTranslation } from 'react-i18next' import SavedItems from '@/app/components/app/text-generate/saved-items' import AppIcon from '@/app/components/base/app-icon' @@ -12,7 +13,6 @@ import Badge from '@/app/components/base/badge' import DifyLogo from '@/app/components/base/logo/dify-logo' import { appDefaultIconBackground } from '@/config' import { AccessMode } from '@/models/access-control' -import TabHeader from '../../base/tab-header' import MenuDropdown from './menu-dropdown' import RunBatch from './run-batch' import RunOnce from './run-once' @@ -71,7 +71,9 @@ const TextGenerationSidebar: FC = ({ const { t } = useTranslation() return ( -
= ({ {siteInfo.description && (
{siteInfo.description}
)} - , - extra: savedMessages.length > 0 - ? ( - - {savedMessages.length} - - ) - : null, - }] - : []), - ]} - value={currentTab} - onChange={onTabChange} - /> + + + {t('generation.tabs.create', { ns: 'share' })} + + + {t('generation.tabs.batch', { ns: 'share' })} + + {!isWorkflow && ( + + + {t('generation.tabs.saved', { ns: 'share' })} + {savedMessages.length > 0 && ( + + {savedMessages.length} + + )} + + )} +
= ({ !isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular', )} > -
+ = ({ onVisionFilesChange={onVisionFilesChange} runControl={runControl} /> -
-
+ + -
- {currentTab === 'saved' && ( - onTabChange('create')} - /> + + {!isWorkflow && ( + + onTabChange('create')} + /> + )}
{!customConfig?.remove_webapp_brand && ( @@ -170,7 +170,7 @@ const TextGenerationSidebar: FC = ({ : } )} - + ) } diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index 793e773e596..8137b0ff3a6 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -274,18 +274,6 @@ vi.mock('../last-run', () => ({ ), })) -vi.mock('../tab', () => ({ - __esModule: true, - TabType: { settings: 'settings', lastRun: 'lastRun' }, - default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => ( -
- - - {value} -
- ), -})) - vi.mock('../trigger-subscription', () => ({ TriggerSubscription: ({ children, onSubscriptionChange }: PropsWithChildren<{ onSubscriptionChange?: (value: { id: string }, callback?: () => void) => void }>) => (
@@ -321,7 +309,7 @@ describe('workflow-panel index', () => { }) it('should render the settings panel and wire title, description, run, and close actions', async () => { - const { container } = renderWorkflowComponent( + renderWorkflowComponent(
panel-child
, @@ -351,8 +339,7 @@ describe('workflow-panel index', () => { fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' })) - const clickableItems = container.querySelectorAll('.cursor-pointer') - fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockHandleSingleRun).toHaveBeenCalledTimes(1) expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1', true) @@ -395,6 +382,7 @@ describe('workflow-panel index', () => { ) expect(screen.getByText('last-run-panel')).toBeInTheDocument() + expect(screen.getByRole('tabpanel')).toHaveClass('flex', 'flex-1', 'flex-col') }) it('should render the plain tab layout and allow last-run status updates', async () => { diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index b53af616838..6abec226ce2 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -2,6 +2,7 @@ import type { FC, ReactNode } from 'react' import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list' import type { Node } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' import { Tooltip, TooltipContent, @@ -90,8 +91,8 @@ import { } from './helpers' import LastRun from './last-run' import useLastRun from './last-run/use-last-run' -import Tab, { TabType } from './tab' import { TriggerSubscription } from './trigger-subscription' +import { TabType } from './types' type BasePanelProps = { children: ReactNode @@ -480,6 +481,17 @@ const BasePanel: FC = ({ ? t('debug.variableInspect.trigger.stop', { ns: 'workflow' }) : runThisStepLabel + const panelTabs = ( + + + {t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase()} + + + {t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase()} + + + ) + return (
= ({ >
-
setTabType(selectedValue)} className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-[width] ease-linear', showSingleRunPanel ? 'overflow-hidden' : 'overflow-y-auto')} style={{ width: `${nodePanelWidth}px`, @@ -558,12 +572,14 @@ const BasePanel: FC = ({
-
handleNodeSelect(id, true)} > - -
+ +
@@ -584,10 +600,7 @@ const BasePanel: FC = ({ }} >
- + {panelTabs} = ({ isAuthorized={currentDataSource.is_authorized} >
- + {panelTabs} = ({ subscriptionIdSelected={data.subscription_id} onSubscriptionChange={handleSubscriptionChange} > - + {panelTabs} ) } { !needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
- + {panelTabs}
) }
- {tabType === TabType.settings && ( -
-
- {cloneElement(children as any, { - id, - data, - panelProps: { - getInputVars, - toVarInputs, - runInputData, - setRunInputData, - runResult, - runInputDataRef, - }, - })} -
- - { - hasRetryNode(data.type) && ( - - ) - } - { - hasErrorHandleNode(data.type) && ( - - ) - } - { - !!availableNextBlocks.length && ( -
-
- {t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()} -
-
- {t('panel.addNextStep', { ns: 'workflow' })} -
- -
- ) - } - {readmeEntranceComponent} + +
+ {cloneElement(children as any, { + id, + data, + panelProps: { + getInputVars, + toVarInputs, + runInputData, + setRunInputData, + runResult, + runInputDataRef, + }, + })}
- )} + + { + hasRetryNode(data.type) && ( + + ) + } + { + hasErrorHandleNode(data.type) && ( + + ) + } + { + !!availableNextBlocks.length && ( +
+
+ {t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()} +
+
+ {t('panel.addNextStep', { ns: 'workflow' })} +
+ +
+ ) + } + {readmeEntranceComponent} +
- {tabType === TabType.lastRun && ( + = ({ isPaused={isPaused} {...passedLogParams} /> - )} + -
+
) } diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index 8505369adfc..b0bbc98d7e4 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -38,7 +38,7 @@ import { BlockEnum } from '@/app/components/workflow/types' import { isSupportCustomRunForm } from '@/app/components/workflow/utils' import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' import { useInvalidLastRun } from '@/service/use-workflow' -import { TabType } from '../tab' +import { TabType } from '../types' const singleRunFormParamsHooks: Record = { [BlockEnum.LLM]: useLLMSingleRunFormParams, diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx deleted file mode 100644 index 0385fd21169..00000000000 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client' -import type { FC } from 'react' -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import TabHeader from '@/app/components/base/tab-header' - -export enum TabType { - settings = 'settings', - lastRun = 'lastRun', - relations = 'relations', -} - -type Props = { - value: TabType - onChange: (value: TabType) => void -} - -const Tab: FC = ({ - value, - onChange, -}) => { - const { t } = useTranslation() - return ( - - ) -} -export default React.memo(Tab) diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/types.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/types.ts new file mode 100644 index 00000000000..00aa7deaf17 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/types.ts @@ -0,0 +1,7 @@ +export const TabType = { + settings: 'settings', + lastRun: 'lastRun', + relations: 'relations', +} as const + +export type TabType = typeof TabType[keyof typeof TabType]