refactor(web): compose tab header with dify-ui tabs (#37280)

This commit is contained in:
yyh 2026-06-10 18:45:22 +08:00 committed by GitHub
parent 2c5c8e82c3
commit a83118c0f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 190 additions and 562 deletions

View File

@ -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

View File

@ -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',
)
})

View File

@ -26,17 +26,11 @@ type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<Tabs defaultValue="overview" className="w-96">
<TabsList className="gap-4 border-b border-divider-subtle">
<TabsTab
value="overview"
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
>
<TabsList>
<TabsTab value="overview">
Overview
</TabsTab>
<TabsTab
value="activity"
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
>
<TabsTab value="activity">
Activity
</TabsTab>
</TabsList>

View File

@ -18,7 +18,7 @@ export function TabsList({
}: TabsListProps) {
return (
<BaseTabs.List
className={cn('flex', className)}
className={cn('flex gap-4', className)}
{...props}
/>
)
@ -34,7 +34,7 @@ export function TabsTab({
}: TabsTabProps) {
return (
<BaseTabs.Tab
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-disabled:cursor-not-allowed data-disabled:text-text-disabled', className)}
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid relative flex cursor-pointer items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold text-text-tertiary data-active:border-components-tab-active data-active:text-text-primary data-disabled:cursor-not-allowed data-disabled:text-text-tertiary data-disabled:opacity-30 data-active:data-disabled:text-text-primary', className)}
{...props}
/>
)

View File

@ -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()

View File

@ -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 = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>

View File

@ -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(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
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(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
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(
<TabHeader
items={mockItems}
value="tab2"
activeItemClassName={activeClass}
onChange={() => { }}
/>,
)
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(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
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(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
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: <span data-testid="tab-icon">🚀</span>,
extra: <span data-testid="tab-extra">New</span>,
},
]
render(<TabHeader items={itemsWithExtras} value="extra" onChange={() => { }} />)
expect(screen.getByTestId('tab-icon')).toBeInTheDocument()
expect(screen.getByTestId('tab-extra')).toBeInTheDocument()
})
it('should apply custom class names for items and wrappers', () => {
render(
<TabHeader
items={mockItems}
value="tab1"
itemClassName="my-text-class"
itemWrapClassName="my-wrap-class"
onChange={() => { }}
/>,
)
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')
})
})

View File

@ -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: <span className="ml-1 rounded-full bg-primary-50 px-2 py-0.5 text-xs text-primary-600">New</span> },
{ 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 (
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
<span>Tabs</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
active="
{activeTab}
"
</code>
</div>
<TabHeader
items={items}
value={activeTab}
onChange={setActiveTab}
/>
</div>
)
}
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<typeof TabHeaderDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -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<ITabHeaderProps> = ({
items,
value,
itemClassName,
itemWrapClassName,
activeItemClassName,
onChange,
}) => {
const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
<div
key={id}
data-testid={`tab-header-item-${id}`}
className={cn(
'relative flex cursor-pointer items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold',
id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary',
disabled && 'cursor-not-allowed opacity-30',
itemWrapClassName,
)}
onClick={() => !disabled && onChange(id)}
>
{icon || ''}
<div className={cn('ml-2', itemClassName)}>{name}</div>
{extra || ''}
</div>
)
return (
<div data-testid="tab-header" className="flex justify-between">
<div data-testid="tab-header-left" className="flex space-x-4">
{items.filter(item => !item.isRight).map(renderItem)}
</div>
<div data-testid="tab-header-right" className="flex space-x-4">
{items.filter(item => item.isRight).map(renderItem)}
</div>
</div>
)
}
export default React.memo(TabHeader)

View File

@ -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()
})

View File

@ -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(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
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(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
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(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
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(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
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')
})
})

View File

@ -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<Props> = ({
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<Props> = ({
<AppUnavailable className="size-auto" isUnknownReason />
</div>
) : (
<div className="flex h-full flex-col">
<Tabs
value={activeType}
onValueChange={selectedValue => setType(selectedValue)}
className="flex h-full flex-col"
>
<div className="flex shrink-0 justify-between pl-4">
<Tab
value={activeType}
onChange={setType}
disableTry={app ? !isTrialApp : false}
/>
<TabsList>
{IS_CLOUD_EDITION && (
<TabsTab
value={TypeEnum.TRY}
disabled={app ? !isTrialApp : false}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">{t('tryApp.tabHeader.try', { ns: 'explore' })}</span>
</TabsTab>
)}
<TabsTab
value={TypeEnum.DETAIL}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">{t('tryApp.tabHeader.detail', { ns: 'explore' })}</span>
</TabsTab>
</TabsList>
<Button
size="large"
variant="tertiary"
aria-label={t('common.operation.close')}
className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text"
onClick={onClose}
>
<span className="i-ri-close-line size-5" />
<span aria-hidden className="i-ri-close-line size-5" />
</Button>
</div>
{/* Main content */}
<div className="mt-2 flex h-0 grow justify-between space-x-2">
{activeType === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail} /> : <Preview appId={appId} appDetail={appDetail} />}
{IS_CLOUD_EDITION && (
<TabsPanel value={TypeEnum.TRY} className="min-w-0 flex-1">
<App appId={appId} appDetail={appDetail} />
</TabsPanel>
)}
<TabsPanel value={TypeEnum.DETAIL} className="min-w-0 flex-1">
<Preview appId={appId} appDetail={appDetail} />
</TabsPanel>
<AppInfo
className="w-[360px] shrink-0"
appDetail={appDetail}
@ -89,7 +116,7 @@ const TryApp: FC<Props> = ({
onCreate={onCreate}
/>
</div>
</div>
</Tabs>
)}
</DialogContent>
</Dialog>

View File

@ -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<Props> = ({
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 (
<TabHeader
items={tabs}
value={value}
onChange={onChange as (value: string) => 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)

View File

@ -0,0 +1,6 @@
export const TypeEnum = {
TRY: 'try',
DETAIL: 'detail',
} as const
export type TypeEnum = typeof TypeEnum[keyof typeof TypeEnum]

View File

@ -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', () => {

View File

@ -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<TextGenerationSidebarProps> = ({
const { t } = useTranslation()
return (
<div
<Tabs
value={currentTab}
onValueChange={onTabChange}
className={cn(
'relative flex h-full shrink-0 flex-col',
isPC ? 'w-[600px] max-w-[50%]' : resultExisted ? 'h-[calc(100%-64px)]' : '',
@ -93,29 +95,25 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
{siteInfo.description && (
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
)}
<TabHeader
items={[
{ id: 'create', name: t('generation.tabs.create', { ns: 'share' }) },
{ id: 'batch', name: t('generation.tabs.batch', { ns: 'share' }) },
...(!isWorkflow
? [{
id: 'saved',
name: t('generation.tabs.saved', { ns: 'share' }),
isRight: true,
icon: <span aria-hidden className="i-ri-bookmark-3-line size-4" />,
extra: savedMessages.length > 0
? (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)
: null,
}]
: []),
]}
value={currentTab}
onChange={onTabChange}
/>
<TabsList className="w-full">
<TabsTab value="create">
<span className="ml-2">{t('generation.tabs.create', { ns: 'share' })}</span>
</TabsTab>
<TabsTab value="batch">
<span className="ml-2">{t('generation.tabs.batch', { ns: 'share' })}</span>
</TabsTab>
{!isWorkflow && (
<TabsTab value="saved" className="ml-auto">
<span aria-hidden className="i-ri-bookmark-3-line size-4" />
<span className="ml-2">{t('generation.tabs.saved', { ns: 'share' })}</span>
{savedMessages.length > 0 && (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)}
</TabsTab>
)}
</TabsList>
</div>
<div
className={cn(
@ -124,7 +122,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<div className={cn(currentTab === 'create' ? 'block' : 'hidden')}>
<TabsPanel value="create" keepMounted>
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
@ -136,22 +134,24 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
onVisionFilesChange={onVisionFilesChange}
runControl={runControl}
/>
</div>
<div className={cn(currentTab === 'batch' ? 'block' : 'hidden')}>
</TabsPanel>
<TabsPanel value="batch" keepMounted>
<RunBatch
vars={promptConfig.prompt_variables}
onSend={onBatchSend}
isAllFinished={allTasksRun}
/>
</div>
{currentTab === 'saved' && (
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={onRemoveSavedMessage}
onStartCreateContent={() => onTabChange('create')}
/>
</TabsPanel>
{!isWorkflow && (
<TabsPanel value="saved">
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={onRemoveSavedMessage}
onStartCreateContent={() => onTabChange('create')}
/>
</TabsPanel>
)}
</div>
{!customConfig?.remove_webapp_brand && (
@ -170,7 +170,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
: <DifyLogo size="small" />}
</div>
)}
</div>
</Tabs>
)
}

View File

@ -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 }) => (
<div>
<button onClick={() => onChange('settings')}>settings-tab</button>
<button onClick={() => onChange('lastRun')}>last-run-tab</button>
<span>{value}</span>
</div>
),
}))
vi.mock('../trigger-subscription', () => ({
TriggerSubscription: ({ children, onSubscriptionChange }: PropsWithChildren<{ onSubscriptionChange?: (value: { id: string }, callback?: () => void) => void }>) => (
<div>
@ -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(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
@ -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 () => {

View File

@ -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<BasePanelProps> = ({
? t('debug.variableInspect.trigger.stop', { ns: 'workflow' })
: runThisStepLabel
const panelTabs = (
<TabsList>
<TabsTab value={TabType.settings}>
{t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase()}
</TabsTab>
<TabsTab value={TabType.lastRun}>
{t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase()}
</TabsTab>
</TabsList>
)
return (
<div
className={cn(
@ -496,8 +508,10 @@ const BasePanel: FC<BasePanelProps> = ({
>
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
</div>
<div
<Tabs
ref={containerRef}
value={tabType}
onValueChange={selectedValue => 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<BasePanelProps> = ({
<HelpLink nodeType={data.type} />
<NodeActionsDropdown id={id} data={data} showHelpLink={false} />
<div className="mx-3 h-3.5 w-px bg-divider-regular" />
<div
className="flex size-6 cursor-pointer items-center justify-center"
<button
type="button"
aria-label={t('common.operation.close')}
className="flex size-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
onClick={() => handleNodeSelect(id, true)}
>
<RiCloseLine className="size-4 text-text-tertiary" />
</div>
<RiCloseLine aria-hidden className="size-4 text-text-tertiary" />
</button>
</div>
</div>
<div className="p-2">
@ -584,10 +600,7 @@ const BasePanel: FC<BasePanelProps> = ({
}}
>
<div className="flex items-center justify-between pr-3 pl-4">
<Tab
value={tabType}
onChange={setTabType}
/>
{panelTabs}
<AuthorizedInNode
pluginPayload={{
provider: currToolCollection?.name || '',
@ -609,10 +622,7 @@ const BasePanel: FC<BasePanelProps> = ({
isAuthorized={currentDataSource.is_authorized}
>
<div className="flex items-center justify-between pr-3 pl-4">
<Tab
value={tabType}
onChange={setTabType}
/>
{panelTabs}
<AuthorizedInDataSourceNode
onJumpToDataSourcePage={handleJumpToDataSourcePage}
authorizationsNum={3}
@ -627,76 +637,68 @@ const BasePanel: FC<BasePanelProps> = ({
subscriptionIdSelected={data.subscription_id}
onSubscriptionChange={handleSubscriptionChange}
>
<Tab
value={tabType}
onChange={setTabType}
/>
{panelTabs}
</TriggerSubscription>
)
}
{
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
<div className="flex items-center justify-between pr-3 pl-4">
<Tab
value={tabType}
onChange={setTabType}
/>
{panelTabs}
</div>
)
}
<Split />
</div>
{tabType === TabType.settings && (
<div className="flex flex-1 flex-col overflow-y-auto">
<div>
{cloneElement(children as any, {
id,
data,
panelProps: {
getInputVars,
toVarInputs,
runInputData,
setRunInputData,
runResult,
runInputDataRef,
},
})}
</div>
<Split />
{
hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
/>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
/>
)
}
{
!!availableNextBlocks.length && (
<div className="border-t-[0.5px] border-divider-regular p-4">
<div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary">
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
</div>
<div className="mb-2 system-xs-regular text-text-tertiary">
{t('panel.addNextStep', { ns: 'workflow' })}
</div>
<NextStep selectedNode={selectedNode} />
</div>
)
}
{readmeEntranceComponent}
<TabsPanel value={TabType.settings} className="flex flex-1 flex-col overflow-y-auto">
<div>
{cloneElement(children as any, {
id,
data,
panelProps: {
getInputVars,
toVarInputs,
runInputData,
setRunInputData,
runResult,
runInputDataRef,
},
})}
</div>
)}
<Split />
{
hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
/>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
/>
)
}
{
!!availableNextBlocks.length && (
<div className="border-t-[0.5px] border-divider-regular p-4">
<div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary">
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
</div>
<div className="mb-2 system-xs-regular text-text-tertiary">
{t('panel.addNextStep', { ns: 'workflow' })}
</div>
<NextStep selectedNode={selectedNode} />
</div>
)
}
{readmeEntranceComponent}
</TabsPanel>
{tabType === TabType.lastRun && (
<TabsPanel value={TabType.lastRun} className="flex flex-1 flex-col">
<LastRun
appId={appDetail?.id || ''}
nodeId={id}
@ -710,9 +712,9 @@ const BasePanel: FC<BasePanelProps> = ({
isPaused={isPaused}
{...passedLogParams}
/>
)}
</TabsPanel>
</div>
</Tabs>
</div>
)
}

View File

@ -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, any> = {
[BlockEnum.LLM]: useLLMSingleRunFormParams,

View File

@ -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<Props> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<TabHeader
items={[
{ id: TabType.settings, name: t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase() },
{ id: TabType.lastRun, name: t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase() },
]}
itemClassName="ml-0"
value={value}
onChange={onChange as any}
/>
)
}
export default React.memo(Tab)

View File

@ -0,0 +1,7 @@
export const TabType = {
settings: 'settings',
lastRun: 'lastRun',
relations: 'relations',
} as const
export type TabType = typeof TabType[keyof typeof TabType]