feat(dify-ui): add Tabs/ToggleGroup (#35965)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-05-09 16:23:50 +08:00 committed by GitHub
parent 1efd365b62
commit 861f73267c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 718 additions and 842 deletions

View File

@ -1051,14 +1051,6 @@
"count": 1
}
},
"web/app/components/base/form/components/field/variable-or-constant-input.tsx": {
"no-console": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/base/form/components/field/variable-selector.tsx": {
"no-console": {
"count": 1
@ -2307,11 +2299,8 @@
}
},
"web/app/components/develop/code.tsx": {
"ts/no-empty-object-type": {
"count": 1
},
"ts/no-explicit-any": {
"count": 9
"count": 7
}
},
"web/app/components/develop/md.tsx": {

View File

@ -77,6 +77,14 @@
"types": "./src/switch/index.tsx",
"import": "./src/switch/index.tsx"
},
"./tabs": {
"types": "./src/tabs/index.tsx",
"import": "./src/tabs/index.tsx"
},
"./toggle-group": {
"types": "./src/toggle-group/index.tsx",
"import": "./src/toggle-group/index.tsx"
},
"./toast": {
"types": "./src/toast/index.tsx",
"import": "./src/toast/index.tsx"

View File

@ -0,0 +1,80 @@
import { render } from 'vitest-browser-react'
import {
Tabs,
TabsList,
TabsPanel,
TabsTab,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Tabs wrappers', () => {
it('renders Base UI tabs with accessible roles', async () => {
const screen = await render(
<Tabs defaultValue="js">
<TabsList>
<TabsTab value="js">JavaScript</TabsTab>
<TabsTab value="py">Python</TabsTab>
</TabsList>
<TabsPanel value="js">JS panel</TabsPanel>
<TabsPanel value="py">Python panel</TabsPanel>
</Tabs>,
)
await expect.element(screen.getByRole('tablist')).toBeInTheDocument()
await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true')
await expect.element(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'false')
await expect.element(screen.getByText('JS panel')).toBeInTheDocument()
})
it('keeps tabs styling minimal by default', async () => {
const screen = await render(
<Tabs defaultValue="first">
<TabsList>
<TabsTab value="first">First</TabsTab>
<TabsTab value="second">Second</TabsTab>
</TabsList>
</Tabs>,
)
await expect.element(screen.getByRole('tablist')).toHaveClass(
'flex',
)
await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass(
'touch-manipulation',
'focus-visible:outline-hidden',
)
})
it('calls onValueChange while leaving controlled value to the caller', async () => {
const onValueChange = vi.fn()
const screen = await render(
<Tabs value="js" onValueChange={onValueChange}>
<TabsList>
<TabsTab value="js">JavaScript</TabsTab>
<TabsTab value="py">Python</TabsTab>
</TabsList>
</Tabs>,
)
asHTMLElement(screen.getByRole('tab', { name: 'Python' }).element()).click()
expect(onValueChange).toHaveBeenCalledWith('py', expect.anything())
await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true')
})
it('forwards className to composable parts', async () => {
const screen = await render(
<Tabs defaultValue="first">
<TabsList className="custom-list">
<TabsTab value="first" className="custom-tab">First</TabsTab>
</TabsList>
<TabsPanel value="first" className="custom-panel">Panel</TabsPanel>
</Tabs>,
)
await expect.element(screen.getByRole('tablist')).toHaveClass('custom-list')
await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass('custom-tab')
expect(screen.getByText('Panel').element()).toHaveClass('custom-panel')
})
})

View File

@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import {
Tabs,
TabsList,
TabsPanel,
TabsTab,
} from '.'
const meta = {
title: 'Base/UI/Tabs',
component: Tabs,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Composable tabs built on Base UI. Use this when a tab controls a corresponding tab panel.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Tabs>
export default meta
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"
>
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"
>
Activity
</TabsTab>
</TabsList>
<TabsPanel value="overview" className="py-3 system-sm-regular text-text-secondary">
Overview panel
</TabsPanel>
<TabsPanel value="activity" className="py-3 system-sm-regular text-text-secondary">
Activity panel
</TabsPanel>
</Tabs>
),
}

View File

@ -0,0 +1,59 @@
'use client'
import type { Tabs as BaseTabsNS } from '@base-ui/react/tabs'
import { Tabs as BaseTabs } from '@base-ui/react/tabs'
import { cn } from '../cn'
export type TabsProps = BaseTabsNS.Root.Props
export const Tabs = BaseTabs.Root
export type TabsListProps = Omit<BaseTabsNS.List.Props, 'className'> & {
className?: string
}
export function TabsList({
className,
...props
}: TabsListProps) {
return (
<BaseTabs.List
className={cn('flex', className)}
{...props}
/>
)
}
export type TabsTabProps = Omit<BaseTabsNS.Tab.Props, 'className'> & {
className?: string
}
export function TabsTab({
className,
...props
}: TabsTabProps) {
return (
<BaseTabs.Tab
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover data-disabled:cursor-not-allowed data-disabled:text-text-disabled', className)}
{...props}
/>
)
}
export type TabsPanelProps = Omit<BaseTabsNS.Panel.Props, 'className'> & {
className?: string
}
export function TabsPanel({
className,
...props
}: TabsPanelProps) {
return (
<BaseTabs.Panel
className={className}
{...props}
/>
)
}
export const TabsIndicator = BaseTabs.Indicator

View File

@ -0,0 +1,74 @@
import { render } from 'vitest-browser-react'
import {
ToggleGroup,
ToggleGroupDivider,
ToggleGroupItem,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('ToggleGroup wrappers', () => {
it('renders a segmented control with Base UI pressed state', async () => {
const screen = await render(
<ToggleGroup defaultValue={['one']} aria-label="View">
<ToggleGroupItem value="one">One</ToggleGroupItem>
<ToggleGroupItem value="two">Two</ToggleGroupItem>
</ToggleGroup>,
)
await expect.element(screen.getByRole('group')).toHaveClass(
'bg-components-segmented-control-bg-normal',
'p-0.5',
'rounded-[10px]',
)
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true')
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass(
'data-pressed:bg-components-segmented-control-item-active-bg',
'data-pressed:text-text-accent-light-mode-only',
)
})
it('uses single selection by default', async () => {
const screen = await render(
<ToggleGroup defaultValue={['one']} aria-label="View">
<ToggleGroupItem value="one">One</ToggleGroupItem>
<ToggleGroupItem value="two">Two</ToggleGroupItem>
</ToggleGroup>,
)
asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click()
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'false')
await expect.element(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('aria-pressed', 'true')
})
it('calls onValueChange while leaving controlled value to the caller', async () => {
const onValueChange = vi.fn()
const screen = await render(
<ToggleGroup value={['one']} onValueChange={onValueChange} aria-label="View">
<ToggleGroupItem value="one">One</ToggleGroupItem>
<ToggleGroupItem value="two">Two</ToggleGroupItem>
</ToggleGroup>,
)
asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click()
expect(onValueChange).toHaveBeenCalledWith(['two'], expect.anything())
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true')
})
it('forwards disabled and className to composable parts', async () => {
const screen = await render(
<ToggleGroup defaultValue={['one']} aria-label="View" className="custom-group">
<ToggleGroupItem value="one" className="custom-item">One</ToggleGroupItem>
<ToggleGroupDivider className="custom-divider" data-testid="divider" />
<ToggleGroupItem value="two" disabled>Two</ToggleGroupItem>
</ToggleGroup>,
)
await expect.element(screen.getByRole('group')).toHaveClass('custom-group')
await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass('custom-item')
await expect.element(screen.getByRole('button', { name: 'Two' })).toBeDisabled()
await expect.element(screen.getByTestId('divider')).toHaveClass('custom-divider')
})
})

View File

@ -0,0 +1,177 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ReactNode } from 'react'
import {
ToggleGroup,
ToggleGroupDivider,
ToggleGroupItem,
} from '.'
const meta = {
title: 'Base/UI/ToggleGroup',
component: ToggleGroup,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Segmented control built on Base UI ToggleGroup and Toggle. Use this for mode, filter, and view selection that does not need tabpanel semantics.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ToggleGroup>
export default meta
type Story = StoryObj<typeof meta>
type SegmentedControlProps = {
defaultValue: string
values: string[]
iconOnly?: boolean
noPadding?: boolean
}
const Icon = () => (
<i className="i-ri-information-line size-4 shrink-0" aria-hidden="true" />
)
const Item = () => (
<>
<Icon />
<span className="px-0.5">Item</span>
</>
)
function SegmentedControl({
defaultValue,
values,
iconOnly = false,
noPadding = false,
}: SegmentedControlProps) {
return (
<ToggleGroup
defaultValue={[defaultValue]}
aria-label="Segmented control"
className={noPadding ? 'rounded-lg border-[0.5px] border-divider-subtle p-0' : undefined}
>
{values.map((itemValue, index) => (
<span key={itemValue} className="relative flex items-center">
<ToggleGroupItem
value={itemValue}
aria-label={iconOnly ? `Item ${index + 1}` : undefined}
>
<Icon />
{!iconOnly && (
<span className="px-0.5">Item</span>
)}
</ToggleGroupItem>
{index === 1 && (
<span className="pointer-events-none absolute top-0 -right-px flex h-full items-center" aria-hidden="true">
<ToggleGroupDivider />
</span>
)}
</span>
))}
</ToggleGroup>
)
}
function SpecColumn() {
const values = ['one', 'two', 'three']
return (
<div className="flex flex-col items-center gap-6">
<SegmentedControl defaultValue="one" values={values} />
<SegmentedControl defaultValue="one" values={values} iconOnly />
<SegmentedControl defaultValue="one" values={values} noPadding />
<SegmentedControl defaultValue="one" values={values} iconOnly noPadding />
</div>
)
}
function SpecPanel({
className,
children,
}: {
className?: string
children: ReactNode
}) {
return (
<div className={className}>
<div className="flex min-h-105 items-center justify-center">
{children}
</div>
</div>
)
}
export const DesignSpec: Story = {
render: () => (
<div className="overflow-hidden rounded-3xl bg-components-panel-bg-alt p-4">
<SpecPanel className="w-120 overflow-hidden rounded-2xl bg-components-chart-bg">
<SpecColumn />
</SpecPanel>
</div>
),
parameters: {
docs: {
description: {
story: 'Figma node 2473:9851: segmented control examples with text+icon and icon-only rows, with and without outer padding.',
},
},
},
}
export const DataAttributeStates: Story = {
render: () => (
<div className="flex flex-col gap-5">
<ToggleGroup defaultValue={['active']} aria-label="Basic states">
<ToggleGroupItem value="default">
<Item />
</ToggleGroupItem>
<ToggleGroupItem value="active">
<Item />
</ToggleGroupItem>
<ToggleGroupItem value="disabled" disabled>
<Item />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup defaultValue={['accent-light']} aria-label="Active states">
<ToggleGroupItem value="accent-light">
<Item />
</ToggleGroupItem>
<ToggleGroupItem
value="neutral"
className="data-pressed:text-text-primary"
>
<Item />
</ToggleGroupItem>
<ToggleGroupItem
value="accent"
className="data-pressed:border-components-segmented-control-item-active-accent-border data-pressed:bg-components-segmented-control-item-active-accent-bg data-pressed:text-text-accent"
>
<Item />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup defaultValue={['one', 'three']} multiple aria-label="Multiple selection">
<ToggleGroupItem value="one">
<Item />
</ToggleGroupItem>
<ToggleGroupItem value="two">
<Item />
</ToggleGroupItem>
<ToggleGroupItem value="three">
<Item />
</ToggleGroupItem>
</ToggleGroup>
</div>
),
parameters: {
docs: {
description: {
story: '`ToggleGroupItem` gets `data-pressed` and `data-disabled` from Base UI. Accent, neutral, and multiple-selection examples are composed through props and className.',
},
},
},
}

View File

@ -0,0 +1,58 @@
'use client'
import type { Toggle as BaseToggleNS } from '@base-ui/react/toggle'
import type { ToggleGroup as BaseToggleGroupNS } from '@base-ui/react/toggle-group'
import type { HTMLAttributes } from 'react'
import { Toggle as BaseToggle } from '@base-ui/react/toggle'
import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group'
import { cn } from '../cn'
export type ToggleGroupProps<Value extends string = string> = Omit<BaseToggleGroupNS.Props<Value>, 'className'> & {
className?: string
}
export function ToggleGroup<Value extends string = string>({
className,
...props
}: ToggleGroupProps<Value>) {
return (
<BaseToggleGroup
className={cn('inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5', className)}
{...props}
/>
)
}
export type ToggleGroupItemProps<Value extends string = string> = Omit<BaseToggleNS.Props<Value>, 'className'> & {
className?: string
}
export function ToggleGroupItem<Value extends string = string>({
className,
...props
}: ToggleGroupItemProps<Value>) {
return (
<BaseToggle
className={cn('relative flex h-7 min-w-0 touch-manipulation items-center justify-center gap-0.5 overflow-hidden whitespace-nowrap rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover data-pressed:border-components-segmented-control-item-active-border data-pressed:bg-components-segmented-control-item-active-bg data-pressed:text-text-accent-light-mode-only data-pressed:shadow-xs data-pressed:shadow-shadow-shadow-3 data-disabled:cursor-not-allowed data-disabled:bg-transparent data-disabled:text-text-disabled data-disabled:shadow-none data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled motion-reduce:transition-none', className)}
{...props}
/>
)
}
export type ToggleGroupDividerProps = Omit<HTMLAttributes<HTMLSpanElement>, 'className'> & {
className?: string
}
export function ToggleGroupDivider({
className,
...props
}: ToggleGroupDividerProps) {
return (
<span
role="presentation"
aria-hidden="true"
className={cn('h-3.5 w-px shrink-0 bg-divider-regular', className)}
{...props}
/>
)
}

View File

@ -1,59 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import VariableOrConstantInputField from '../variable-or-constant-input'
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ onChange }: { onChange?: () => void }) => (
<button onClick={() => onChange?.()}>
Variable picker
</button>
),
}))
describe('VariableOrConstantInputField', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render variable picker by default', () => {
render(<VariableOrConstantInputField label="Input source" />)
expect(screen.getByRole('button', { name: 'Variable picker' }))!.toBeInTheDocument()
})
it('should switch to constant input when users choose constant', () => {
render(<VariableOrConstantInputField label="Input source" />)
fireEvent.click(screen.getAllByRole('button')[1]!)
expect(screen.queryByRole('button', { name: 'Variable picker' })).not.toBeInTheDocument()
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should show typed constant value in the input', () => {
render(<VariableOrConstantInputField label="Input source" />)
fireEvent.click(screen.getAllByRole('button')[1]!)
const textbox = screen.getByRole('textbox')
fireEvent.change(textbox, { target: { value: 'constant-value' } })
expect(textbox)!.toHaveValue('constant-value')
})
it('should switch back to variable mode when users choose variable again', () => {
render(<VariableOrConstantInputField label="Input source" />)
const modeButtons = screen.getAllByRole('button')
fireEvent.click(modeButtons[1]!)
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
fireEvent.click(modeButtons[0]!)
expect(screen.getByRole('button', { name: 'Variable picker' }))!.toBeInTheDocument()
})
it('should handle variable picker changes', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { })
try {
render(<VariableOrConstantInputField label="Input source" />)
fireEvent.click(screen.getByRole('button', { name: 'Variable picker' }))
expect(logSpy).toHaveBeenCalledWith('Variable value changed')
}
finally {
logSpy.mockRestore()
}
})
})

View File

@ -1,86 +0,0 @@
import type { ChangeEvent } from 'react'
import type { LabelProps } from '../label'
import { cn } from '@langgenius/dify-ui/cn'
import { RiEditLine } from '@remixicon/react'
import { useCallback, useState } from 'react'
import { VariableX } from '@/app/components/base/icons/src/vender/workflow'
import Input from '@/app/components/base/input'
import SegmentedControl from '@/app/components/base/segmented-control'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import Label from '../label'
type VariableOrConstantInputFieldProps = {
label: string
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
className?: string
}
const VariableOrConstantInputField = ({
className,
label,
labelOptions,
}: VariableOrConstantInputFieldProps) => {
const [variableType, setVariableType] = useState('variable')
const options = [
{
Icon: VariableX,
value: 'variable',
},
{
Icon: RiEditLine,
value: 'constant',
},
]
const handleVariableOrConstantChange = useCallback((value: string) => {
setVariableType(value)
}, [setVariableType])
const handleVariableValueChange = () => {
console.log('Variable value changed')
}
const handleConstantValueChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log('Constant value changed:', e.target.value)
}
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor="variable-or-constant"
label={label}
{...(labelOptions ?? {})}
/>
<div className="flex items-center">
<SegmentedControl
className="mr-1 shrink-0"
value={variableType}
onChange={handleVariableOrConstantChange as any}
options={options as any}
/>
{
variableType === 'variable' && (
<VarReferencePicker
className="grow"
nodeId=""
readonly={false}
value={[]}
onChange={handleVariableValueChange}
/>
)
}
{
variableType === 'constant' && (
<Input
className="ml-1"
onChange={handleConstantValueChange}
/>
)
}
</div>
</div>
)
}
export default VariableOrConstantInputField

View File

@ -1,114 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SegmentedControl from '../index'
describe('SegmentedControl', () => {
const options = [
{ value: 'option1', text: 'Option 1' },
{ value: 'option2', text: 'Option 2' },
{ value: 'option3', text: 'Option 3' },
]
const optionsWithDisabled = [
{ value: 'option1', text: 'Option 1' },
{ value: 'option2', text: 'Option 2', disabled: true },
{ value: 'option3', text: 'Option 3' },
]
const onSelectMock = vi.fn((value: string | number | symbol) => value)
beforeEach(() => {
onSelectMock.mockClear()
})
it('renders all options correctly', () => {
render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />)
options.forEach((option) => {
expect(screen.getByText(option.text)).toBeInTheDocument()
})
const divider = screen.getByTestId('segmented-control-divider-1')
expect(divider).toBeInTheDocument()
})
it('renders with custom activeClassName when provided', () => {
render(
<SegmentedControl
options={options}
value="option1"
onChange={onSelectMock}
activeClassName="custom-active-class"
/>,
)
const selectedOption = screen.getByText('Option 1').closest('button')
expect(selectedOption).toHaveClass('custom-active-class')
})
it('highlights the selected option', () => {
render(<SegmentedControl options={options} value="option2" onChange={onSelectMock} />)
const selectedOption = screen.getByText('Option 2').closest('button')
expect(selectedOption).toHaveClass('sc-active')
})
it('calls onChange when an option is clicked', () => {
render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />)
fireEvent.click(screen.getByText('Option 3'))
expect(onSelectMock).toHaveBeenCalledWith('option3')
})
it('does not call onChange when clicking the already selected option', () => {
render(<SegmentedControl options={options} value="option1" onChange={onSelectMock} />)
fireEvent.click(screen.getByText('Option 1'))
expect(onSelectMock).not.toHaveBeenCalled()
})
it('handles disabled state correctly', () => {
render(<SegmentedControl options={optionsWithDisabled} value="option1" onChange={onSelectMock} />)
fireEvent.click(screen.getByText('Option 2'))
expect(onSelectMock).not.toHaveBeenCalled()
const optionElement = screen.getByText('Option 2').closest('button')
expect(optionElement).toHaveAttribute('disabled')
expect(optionElement).toHaveClass('sc-disabled')
fireEvent.click(screen.getByText('Option 3'))
expect(onSelectMock).toHaveBeenCalledWith('option3')
})
it('renders with custom className when provided', () => {
const customClass = 'my-custom-class'
render(
<SegmentedControl
options={options}
value="option1"
onChange={onSelectMock}
className={customClass}
/>,
)
const selectedOption = screen.getByText('Option 1').closest('button')?.closest('div')
expect(selectedOption).toHaveClass(customClass)
})
it('renders Icon when provided', () => {
const MockIcon = () => <svg data-testid="mock-icon" />
const optionsWithIcon = [
{ value: 'option1', text: 'Option 1', Icon: MockIcon },
]
render(<SegmentedControl options={optionsWithIcon} value="option1" onChange={onSelectMock} />)
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
})
it('renders count when provided and size is large', () => {
const optionsWithCount = [
{ value: 'option1', text: 'Option 1', count: 42 },
]
render(<SegmentedControl options={optionsWithCount} value="option1" onChange={onSelectMock} size="large" />)
expect(screen.getByText('42')).toBeInTheDocument()
})
})

View File

@ -1,109 +0,0 @@
@utility segmented-control {
@apply flex items-center bg-components-segmented-control-bg-normal gap-x-px;
}
@utility segmented-control-regular {
@apply rounded-lg;
&.sc-padding {
@apply p-0.5;
}
}
@utility segmented-control-large {
@apply rounded-lg;
&.sc-padding {
@apply p-0.5;
}
}
@utility sc-padding {
&.segmented-control-large {
@apply p-0.5;
}
& .segmented-control-regular {
@apply p-0.5;
}
&.segmented-control-small {
@apply p-px;
}
}
@utility segmented-control-small {
@apply rounded-md;
&.sc-padding {
@apply p-px;
}
}
@utility sc-no-padding {
@apply border-[0.5px] border-divider-subtle;
}
@utility segmented-control-item {
@apply flex items-center justify-center relative border-[0.5px] border-transparent;
}
@utility segmented-control-item-regular {
@apply px-2 h-7 gap-x-0.5 rounded-lg;
}
@utility segmented-control-item-small {
@apply p-px h-[22px] rounded-md;
}
@utility segmented-control-item-large {
@apply px-2.5 h-8 gap-x-0.5 rounded-lg;
}
@utility segmented-control-item-disabled {
@apply cursor-not-allowed text-text-disabled;
}
@utility sc-default {
@apply hover:bg-state-base-hover text-text-tertiary hover:text-text-secondary;
}
@utility sc-active {
@apply border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3 text-text-secondary;
&.sc-accent {
@apply text-text-accent;
}
&.sc-accent-light {
@apply text-text-accent-light-mode-only;
}
}
@utility sc-disabled {
@apply cursor-not-allowed text-text-disabled hover:text-text-disabled bg-transparent hover:bg-transparent;
}
@utility sc-accent {
&.sc-active {
@apply text-text-accent;
}
}
@utility sc-accent-light {
&.sc-active {
@apply text-text-accent-light-mode-only;
}
}
@utility sc-item-text-regular {
@apply p-0.5;
}
@utility sc-item-text-small {
@apply p-0.5 pr-1;
}
@utility sc-item-text-large {
@apply px-0.5;
}

View File

@ -1,94 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { RiLineChartLine, RiListCheck2, RiRobot2Line } from '@remixicon/react'
import { useState } from 'react'
import { SegmentedControl } from '.'
const SEGMENTS = [
{ value: 'overview', text: 'Overview', Icon: RiLineChartLine },
{ value: 'tasks', text: 'Tasks', Icon: RiListCheck2, count: 8 },
{ value: 'agents', text: 'Agents', Icon: RiRobot2Line },
]
const SegmentedControlDemo = ({
initialValue = 'overview',
size = 'regular',
padding = 'with',
activeState = 'default',
}: {
initialValue?: string
size?: 'regular' | 'small' | 'large'
padding?: 'none' | 'with'
activeState?: 'default' | 'accent' | 'accentLight'
}) => {
const [value, setValue] = useState(initialValue)
return (
<div className="flex w-full max-w-lg flex-col gap-4 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>Segmented control</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
value="
{value}
"
</code>
</div>
<SegmentedControl
options={SEGMENTS}
value={value}
onChange={setValue}
size={size}
padding={padding}
activeState={activeState}
/>
</div>
)
}
const meta = {
title: 'Base/Data Entry/SegmentedControl',
component: SegmentedControlDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Multi-tab segmented control with optional icons and badge counts. Adjust sizing and accent states via controls.',
},
},
},
argTypes: {
initialValue: {
control: 'radio',
options: SEGMENTS.map(segment => segment.value),
},
size: {
control: 'inline-radio',
options: ['small', 'regular', 'large'],
},
padding: {
control: 'inline-radio',
options: ['none', 'with'],
},
activeState: {
control: 'inline-radio',
options: ['default', 'accent', 'accentLight'],
},
},
args: {
initialValue: 'overview',
size: 'regular',
padding: 'with',
activeState: 'default',
},
tags: ['autodocs'],
} satisfies Meta<typeof SegmentedControlDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const AccentState: Story = {
args: {
activeState: 'accent',
},
}

View File

@ -1,151 +0,0 @@
import type { RemixiconComponentType } from '@remixicon/react'
import type { VariantProps } from 'class-variance-authority'
import { cn } from '@langgenius/dify-ui/cn'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import Divider from '../divider'
type SegmentedControlOption<T> = {
value: T
text?: string
Icon?: RemixiconComponentType
count?: number
disabled?: boolean
}
type SegmentedControlProps<T extends string | number | symbol> = {
options: SegmentedControlOption<T>[]
value: T
onChange: (value: T) => void
className?: string
activeClassName?: string
btnClassName?: string
}
const SegmentedControlVariants = cva(
'segmented-control',
{
variants: {
size: {
regular: 'segmented-control-regular',
small: 'segmented-control-small',
large: 'segmented-control-large',
},
padding: {
none: 'sc-no-padding',
with: 'sc-padding',
},
},
defaultVariants: {
size: 'regular',
padding: 'with',
},
},
)
const SegmentedControlItemVariants = cva(
'segmented-control-item disabled:segmented-control-item-disabled',
{
variants: {
size: {
regular: ['segmented-control-item-regular', 'system-sm-medium'],
small: ['segmented-control-item-small', 'system-xs-medium'],
large: ['segmented-control-item-large', 'system-md-semibold'],
},
activeState: {
default: '',
accent: 'sc-accent',
accentLight: 'sc-accent-light',
},
},
defaultVariants: {
size: 'regular',
activeState: 'default',
},
},
)
const ItemTextWrapperVariants = cva(
'',
{
variants: {
size: {
regular: 'sc-item-text-regular',
small: 'sc-item-text-small',
large: 'sc-item-text-large',
},
},
defaultVariants: {
size: 'regular',
},
},
)
export const SegmentedControl = <T extends string | number | symbol>({
options,
value,
onChange,
className,
size,
padding,
activeState,
activeClassName,
btnClassName,
}: SegmentedControlProps<T>
& VariantProps<typeof SegmentedControlVariants>
& VariantProps<typeof SegmentedControlItemVariants>
& VariantProps<typeof ItemTextWrapperVariants>) => {
const selectedOptionIndex = options.findIndex(option => option.value === value)
return (
<div className={cn(
SegmentedControlVariants({ size, padding }),
className,
)}
>
{options.map((option, index) => {
const { Icon, text, count, disabled } = option
const isSelected = index === selectedOptionIndex
const isNextSelected = index === selectedOptionIndex - 1
const isLast = index === options.length - 1
return (
<button
type="button"
key={String(option.value)}
className={cn(
isSelected ? 'sc-active' : 'sc-default',
SegmentedControlItemVariants({ size, activeState: isSelected ? activeState : 'default' }),
isSelected && activeClassName,
disabled && 'sc-disabled',
btnClassName,
)}
onClick={() => {
if (!isSelected)
onChange(option.value)
}}
disabled={disabled}
>
{Icon && <Icon className="size-4 shrink-0" />}
{text && (
<div className={cn('inline-flex items-center gap-x-1', ItemTextWrapperVariants({ size }))}>
<span>{text}</span>
{!!(count && size === 'large') && (
<div className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] system-2xs-medium-uppercase text-text-tertiary">
{count}
</div>
)}
</div>
)}
{!isLast && !isSelected && !isNextSelected && (
<div data-testid={`segmented-control-divider-${index}`} className="absolute top-0 -right-px flex h-full items-center">
<Divider type="vertical" className="mx-0 h-3.5" />
</div>
)}
</button>
)
})}
</div>
)
}
export default React.memo(SegmentedControl)

View File

@ -139,6 +139,7 @@ describe('code.tsx components', () => {
await waitFor(() => {
expect(screen.getByText('second content')).toBeInTheDocument()
})
expect(tab2).toHaveAttribute('aria-selected', 'true')
})
it('should use "Code" as default title when title not provided', () => {
@ -329,7 +330,8 @@ describe('code.tsx components', () => {
<pre><code>fallback</code></pre>
</CodeGroup>,
)
expect(screen.getByRole('tablist')).toBeInTheDocument()
expect(screen.getByRole('tablist')).toHaveClass('-mb-px', 'gap-4', 'bg-transparent')
expect(screen.getByRole('tab', { name: 'cURL' })).toHaveClass('data-active:text-emerald-400')
})
})

View File

@ -1,7 +1,12 @@
'use client'
import type { PropsWithChildren, ReactElement, ReactNode } from 'react'
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Tabs,
TabsList,
TabsPanel,
TabsTab,
} from '@langgenius/dify-ui/tabs'
import {
Children,
createContext,
@ -103,6 +108,11 @@ type CodeExample = {
code: string
}
type CodeTab = {
title: string
value: string
}
type ICodePanelProps = {
children?: React.ReactNode
tag?: string
@ -142,12 +152,11 @@ function CodePanel({ tag, label, children, targetCode }: ICodePanelProps) {
type CodeGroupHeaderProps = {
title?: string
tabTitles?: string[]
selectedIndex?: number
tabs?: CodeTab[]
}
function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderProps) {
const hasTabs = (tabTitles?.length ?? 0) > 1
function CodeGroupHeader({ title, tabs }: CodeGroupHeaderProps) {
const hasTabs = (tabs?.length ?? 0) > 1
return (
<div className="flex min-h-[calc(--spacing(12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent">
@ -157,18 +166,19 @@ function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderPro
</h3>
)}
{hasTabs && (
<TabList className="-mb-px flex gap-4 text-xs font-medium">
{tabTitles!.map((tabTitle, tabIndex) => (
<Tab
key={tabIndex}
className={cn('border-b py-3 transition focus:not-focus-visible:outline-hidden', tabIndex === selectedIndex
? 'border-emerald-500 text-emerald-400'
: 'border-transparent text-zinc-400 hover:text-zinc-300')}
<TabsList
className="-mb-px flex gap-4 rounded-none bg-transparent p-0 text-xs font-medium"
>
{tabs!.map(tab => (
<TabsTab
key={tab.value}
value={tab.value}
className="h-auto rounded-none border-0 border-b border-transparent bg-transparent px-0 py-3 text-xs font-medium text-zinc-400 shadow-none transition hover:bg-transparent hover:text-zinc-300 focus:not-focus-visible:outline-hidden focus-visible:ring-0 data-active:border-emerald-500 data-active:bg-transparent data-active:text-emerald-400 data-active:shadow-none"
>
{tabTitle}
</Tab>
{tab.title}
</TabsTab>
))}
</TabList>
</TabsList>
)}
</div>
)
@ -176,19 +186,24 @@ function CodeGroupHeader({ title, tabTitles, selectedIndex }: CodeGroupHeaderPro
type ICodeGroupPanelsProps = PropsWithChildren<{
targetCode?: CodeExample[]
tabs?: CodeTab[]
[key: string]: any
}>
function CodeGroupPanels({ children, targetCode, ...props }: ICodeGroupPanelsProps) {
if ((targetCode?.length ?? 0) > 1) {
function CodeGroupPanels({ children, targetCode, tabs, ...props }: ICodeGroupPanelsProps) {
if ((targetCode?.length ?? 0) > 1 && tabs) {
return (
<TabPanels>
{targetCode!.map((code, index) => (
<TabPanel key={code.title || code.tag || index}>
<CodePanel {...props} targetCode={code} />
</TabPanel>
))}
</TabPanels>
<>
{targetCode!.map((code, index) => {
const tab = tabs[index]
return (
<TabsPanel key={code.title || code.tag || index} value={tab?.value ?? String(index)}>
<CodePanel {...props} targetCode={code} />
</TabsPanel>
)
})}
</>
)
}
@ -201,18 +216,27 @@ function usePreventLayoutShift() {
useEffect(() => {
return () => {
window.cancelAnimationFrame(rafRef.current)
if (rafRef.current)
window.cancelAnimationFrame(rafRef.current)
}
}, [])
return {
positionRef,
preventLayoutShift(callback: () => {}) {
preventLayoutShift(callback: () => void) {
if (!positionRef.current) {
callback()
return
}
const initialTop = positionRef.current.getBoundingClientRect().top
callback()
rafRef.current = window.requestAnimationFrame(() => {
if (!positionRef.current)
return
const newTop = positionRef.current.getBoundingClientRect().top
window.scrollBy(0, newTop - initialTop)
})
@ -220,27 +244,27 @@ function usePreventLayoutShift() {
}
}
function useTabGroupProps(availableLanguages: string[]) {
const [preferredLanguages, addPreferredLanguage] = useState<any>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const activeLanguage = [...(availableLanguages || [])].sort(
(a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a),
)[0]
const languageIndex = availableLanguages?.indexOf(activeLanguage!) || 0
const newSelectedIndex = languageIndex === -1 ? selectedIndex : languageIndex
if (newSelectedIndex !== selectedIndex)
setSelectedIndex(newSelectedIndex)
function useTabGroupProps(tabValues: string[]) {
const [selectedValue, setSelectedValue] = useState(tabValues[0] ?? '')
const { positionRef, preventLayoutShift } = usePreventLayoutShift()
const value = tabValues.includes(selectedValue)
? selectedValue
: tabValues[0] ?? ''
return {
as: 'div',
ref: positionRef,
selectedIndex,
onChange: (newSelectedIndex: number) => {
preventLayoutShift(() =>
(addPreferredLanguage(availableLanguages[newSelectedIndex]) as any),
)
value,
onValueChange: (newValue: string | number | null) => {
if (newValue == null)
return
const nextValue = String(newValue)
if (!tabValues.includes(nextValue))
return
preventLayoutShift(() => {
setSelectedValue(nextValue)
})
},
}
}
@ -260,24 +284,35 @@ type CodeGroupProps = PropsWithChildren<{
export function CodeGroup({ children, title, targetCode, ...props }: CodeGroupProps) {
const examples = typeof targetCode === 'string' ? [{ code: targetCode }] as CodeExample[] : targetCode
const tabTitles = examples?.map(({ title }) => title || 'Code') || []
const tabGroupProps = useTabGroupProps(tabTitles)
const hasTabs = tabTitles.length > 1
const Container = hasTabs ? TabGroup : 'div'
const containerProps = hasTabs ? tabGroupProps : {}
const headerProps = hasTabs
? { selectedIndex: tabGroupProps.selectedIndex, tabTitles }
: {}
const tabs = examples?.map(({ title }, index) => ({
title: title || 'Code',
value: String(index),
})) || []
const tabGroupProps = useTabGroupProps(tabs.map(tab => tab.value))
const hasTabs = tabs.length > 1
const content = (
<>
<CodeGroupHeader title={title} tabs={hasTabs ? tabs : undefined} />
<CodeGroupPanels {...props} targetCode={examples} tabs={hasTabs ? tabs : undefined}>{children}</CodeGroupPanels>
</>
)
return (
<CodeGroupContext.Provider value={true}>
<Container
{...containerProps}
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
>
<CodeGroupHeader title={title} {...headerProps} />
<CodeGroupPanels {...props} targetCode={examples}>{children}</CodeGroupPanels>
</Container>
{hasTabs
? (
<Tabs
{...tabGroupProps}
className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10"
>
{content}
</Tabs>
)
: (
<div className="not-prose my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10">
{content}
</div>
)}
</CodeGroupContext.Provider>
)
}

View File

@ -33,7 +33,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
vi.mock('../structure-output', () => ({
__esModule: true,
default: (props: { className?: string, value?: StructuredOutput, onChange: (value: StructuredOutput) => void }) => {
StructureOutput: (props: { className?: string, value?: StructuredOutput, onChange: (value: StructuredOutput) => void }) => {
mockStructureOutput(props)
return <div data-testid="structure-output">structured-output</div>
},

View File

@ -1,8 +1,6 @@
import type { FC } from 'react'
import type { SchemaRoot } from '../../types'
import * as React from 'react'
import Modal from '../../../../../base/modal'
import JsonSchemaConfig from './json-schema-config'
import { JsonSchemaConfig } from './json-schema-config'
type JsonSchemaConfigModalProps = {
isShow: boolean
@ -11,12 +9,12 @@ type JsonSchemaConfigModalProps = {
onClose: () => void
}
const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
export function JsonSchemaConfigModal({
isShow,
defaultSchema,
onSave,
onClose,
}) => {
}: JsonSchemaConfigModalProps) {
return (
<Modal
isShow={isShow}
@ -31,5 +29,3 @@ const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
</Modal>
)
}
export default JsonSchemaConfigModal

View File

@ -1,13 +1,12 @@
import type { FC } from 'react'
import type { SchemaRoot } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { SegmentedControl } from '../../../../../base/segmented-control'
import { Type } from '../../types'
import {
checkJsonSchemaDepth,
@ -35,11 +34,15 @@ enum SchemaView {
JsonSchema = 'jsonSchema',
}
const TimelineViewIcon: FC<{ className?: string }> = ({ className }) => {
type IconProps = {
className?: string
}
function TimelineViewIcon({ className }: IconProps) {
return <span className={cn('i-ri-timeline-view', className)} />
}
const BracesIcon: FC<{ className?: string }> = ({ className }) => {
function BracesIcon({ className }: IconProps) {
return <span className={cn('i-ri-braces-line', className)} />
}
@ -55,13 +58,13 @@ const DEFAULT_SCHEMA: SchemaRoot = {
additionalProperties: false,
}
const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
function JsonSchemaConfigContent({
defaultSchema,
onSave,
onClose,
}) => {
}: JsonSchemaConfigProps) {
const { t } = useTranslation()
const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
const [currentTab, setCurrentTab] = useState<readonly SchemaView[]>([SchemaView.VisualEditor])
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2))
const [btnWidth, setBtnWidth] = useState(0)
@ -73,15 +76,16 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
const { emit } = useMittContext()
const selectedTab = currentTab[0] ?? SchemaView.VisualEditor
const updateBtnWidth = useCallback((width: number) => {
function updateBtnWidth(width: number) {
setBtnWidth(width + 32)
}, [])
}
const handleTabChange = useCallback((value: SchemaView) => {
if (currentTab === value)
function handleTabChange(value: SchemaView) {
if (selectedTab === value)
return
if (currentTab === SchemaView.JsonSchema) {
if (selectedTab === SchemaView.JsonSchema) {
try {
const schema = JSON.parse(json)
setParseError(null)
@ -112,41 +116,41 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
return
}
}
else if (currentTab === SchemaView.VisualEditor) {
else if (selectedTab === SchemaView.VisualEditor) {
if (advancedEditing || isAddingNewField)
emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) })
else
setJson(JSON.stringify(jsonSchema, null, 2))
}
setCurrentTab(value)
}, [currentTab, jsonSchema, json, advancedEditing, isAddingNewField, emit])
setCurrentTab([value])
}
const handleApplySchema = useCallback((schema: SchemaRoot) => {
if (currentTab === SchemaView.VisualEditor)
function handleApplySchema(schema: SchemaRoot) {
if (selectedTab === SchemaView.VisualEditor)
setJsonSchema(schema)
else if (currentTab === SchemaView.JsonSchema)
else if (selectedTab === SchemaView.JsonSchema)
setJson(JSON.stringify(schema, null, 2))
}, [currentTab])
}
const handleSubmit = useCallback((schema: Record<string, unknown>) => {
function handleSubmit(schema: Record<string, unknown>) {
const jsonSchema = jsonToSchema(schema) as SchemaRoot
if (currentTab === SchemaView.VisualEditor)
if (selectedTab === SchemaView.VisualEditor)
setJsonSchema(jsonSchema)
else if (currentTab === SchemaView.JsonSchema)
else if (selectedTab === SchemaView.JsonSchema)
setJson(JSON.stringify(jsonSchema, null, 2))
}, [currentTab])
}
const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => {
function handleVisualEditorUpdate(schema: SchemaRoot) {
setJsonSchema(schema)
}, [])
}
const handleSchemaEditorUpdate = useCallback((schema: string) => {
function handleSchemaEditorUpdate(schema: string) {
setJson(schema)
}, [])
}
const handleResetDefaults = useCallback(() => {
if (currentTab === SchemaView.VisualEditor) {
function handleResetDefaults() {
if (selectedTab === SchemaView.VisualEditor) {
setHoveringProperty(null)
if (advancedEditing)
setAdvancedEditing(false)
@ -155,15 +159,15 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
}
setJsonSchema(DEFAULT_SCHEMA)
setJson(JSON.stringify(DEFAULT_SCHEMA, null, 2))
}, [currentTab, advancedEditing, isAddingNewField, setAdvancedEditing, setIsAddingNewField, setHoveringProperty])
}
const handleCancel = useCallback(() => {
function handleCancel() {
onClose()
}, [onClose])
}
const handleSave = useCallback(() => {
function handleSave() {
let schema = jsonSchema
if (currentTab === SchemaView.JsonSchema) {
if (selectedTab === SchemaView.JsonSchema) {
try {
schema = JSON.parse(json)
setParseError(null)
@ -194,7 +198,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
return
}
}
else if (currentTab === SchemaView.VisualEditor) {
else if (selectedTab === SchemaView.VisualEditor) {
if (advancedEditing || isAddingNewField) {
toast.warning(t('nodes.llm.jsonSchema.warningTips.saveSchema', { ns: 'workflow' }))
return
@ -202,7 +206,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
}
onSave(schema)
onClose()
}, [currentTab, jsonSchema, json, onSave, onClose, advancedEditing, isAddingNewField, t])
}
return (
<div className="flex h-full flex-col">
@ -211,18 +215,34 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
<div className="grow truncate title-2xl-semi-bold text-text-primary">
{t('nodes.llm.jsonSchema.title', { ns: 'workflow' })}
</div>
<div className="absolute top-5 right-5 flex h-8 w-8 items-center justify-center p-1.5" onClick={onClose}>
<button
type="button"
className="absolute top-5 right-5 flex h-8 w-8 items-center justify-center p-1.5"
aria-label={t('operation.close', { ns: 'common' })}
onClick={onClose}
>
<span className="i-ri-close-line h-[18px] w-[18px] text-text-tertiary" />
</div>
</button>
</div>
{/* Content */}
<div className="flex items-center justify-between px-6 py-2">
{/* Tab */}
<SegmentedControl<SchemaView>
options={VIEW_TABS}
<ToggleGroup<SchemaView>
aria-label={t('nodes.llm.jsonSchema.title', { ns: 'workflow' })}
value={currentTab}
onChange={handleTabChange}
/>
onValueChange={(nextTab) => {
const value = nextTab[0]
if (value)
handleTabChange(value)
}}
>
{VIEW_TABS.map(({ Icon, text, value }) => (
<ToggleGroupItem key={value} value={value}>
<Icon className="size-4 shrink-0" />
<span className="p-0.5">{text}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-x-0.5">
{/* JSON Schema Generator */}
<JsonSchemaGenerator
@ -238,13 +258,13 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
</div>
</div>
<div className="flex grow flex-col gap-y-1 overflow-hidden px-6">
{currentTab === SchemaView.VisualEditor && (
{selectedTab === SchemaView.VisualEditor && (
<VisualEditor
schema={jsonSchema}
onChange={handleVisualEditorUpdate}
/>
)}
{currentTab === SchemaView.JsonSchema && (
{selectedTab === SchemaView.JsonSchema && (
<SchemaEditor
schema={json}
onUpdate={handleSchemaEditorUpdate}
@ -276,14 +296,12 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
)
}
const JsonSchemaConfigWrapper: FC<JsonSchemaConfigProps> = (props) => {
export function JsonSchemaConfig(props: JsonSchemaConfigProps) {
return (
<MittProvider>
<VisualEditorContextProvider>
<JsonSchemaConfig {...props} />
<JsonSchemaConfigContent {...props} />
</VisualEditorContextProvider>
</MittProvider>
)
}
export default JsonSchemaConfigWrapper

View File

@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import StructureOutput from './structure-output'
import { StructureOutput } from './structure-output'
type Props = {
readOnly: boolean

View File

@ -1,16 +1,12 @@
'use client'
import type { FC } from 'react'
import type { SchemaRoot, StructuredOutput } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiEditLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import ShowPanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
import { Type } from '../types'
import JsonSchemaConfigModal from './json-schema-config-modal'
import { JsonSchemaConfigModal } from './json-schema-config-modal'
type Props = {
className?: string
@ -18,22 +14,23 @@ type Props = {
onChange: (value: StructuredOutput) => void
}
const StructureOutput: FC<Props> = ({
export function StructureOutput({
className,
value,
onChange,
}) => {
}: Props) {
const { t } = useTranslation()
const [showConfig, {
setTrue: showConfigModal,
setFalse: hideConfigModal,
}] = useBoolean(false)
const handleChange = useCallback((value: SchemaRoot) => {
function handleChange(value: SchemaRoot) {
onChange({
schema: value,
})
}, [onChange])
}
return (
<div className={cn(className)}>
<div className="flex justify-between">
@ -47,7 +44,7 @@ const StructureOutput: FC<Props> = ({
className="flex"
onClick={showConfigModal}
>
<RiEditLine className="mr-1 size-3.5" />
<i className="mr-1 i-ri-edit-line size-3.5" aria-hidden="true" />
<div className="system-xs-medium text-components-button-secondary-text">{t('structOutput.configure', { ns: 'app' })}</div>
</Button>
</div>
@ -58,7 +55,13 @@ const StructureOutput: FC<Props> = ({
/>
)
: (
<div className="mt-1.5 flex h-10 cursor-pointer items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary" onClick={showConfigModal}>{t('structOutput.notConfiguredTip', { ns: 'app' })}</div>
<button
type="button"
className="mt-1.5 flex h-10 w-full cursor-pointer items-center justify-center rounded-[10px] bg-background-section system-xs-regular text-text-tertiary"
onClick={showConfigModal}
>
{t('structOutput.notConfiguredTip', { ns: 'app' })}
</button>
)}
{showConfig && (
@ -77,4 +80,3 @@ const StructureOutput: FC<Props> = ({
</div>
)
}
export default React.memo(StructureOutput)

View File

@ -3,7 +3,6 @@ import type { ScheduleTriggerNodeType } from '../../types'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import FrequencySelector from '../frequency-selector'
import ModeSwitcher from '../mode-switcher'
import ModeToggle from '../mode-toggle'
import MonthlyDaysSelector from '../monthly-days-selector'
import NextExecutionTimes from '../next-execution-times'
@ -53,16 +52,6 @@ describe('trigger-schedule components', () => {
})
})
it('should switch between visual and cron modes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ModeSwitcher mode="visual" onChange={onChange} />)
await user.click(screen.getByText('workflow.nodes.triggerSchedule.modeCron'))
expect(onChange).toHaveBeenCalledWith('cron')
})
it('should toggle the mode from visual to cron', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -1,16 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ModeSwitcher from '../mode-switcher'
describe('trigger-schedule/mode-switcher', () => {
it('switches between visual and cron modes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ModeSwitcher mode="visual" onChange={onChange} />)
await user.click(screen.getByText('workflow.nodes.triggerSchedule.modeCron'))
expect(onChange).toHaveBeenCalledWith('cron')
})
})

View File

@ -1,37 +0,0 @@
import type { ScheduleMode } from '../types'
import { RiCalendarLine, RiCodeLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { SegmentedControl } from '@/app/components/base/segmented-control'
type ModeSwitcherProps = {
mode: ScheduleMode
onChange: (mode: ScheduleMode) => void
}
const ModeSwitcher = ({ mode, onChange }: ModeSwitcherProps) => {
const { t } = useTranslation()
const options = [
{
Icon: RiCalendarLine,
text: t('nodes.triggerSchedule.modeVisual', { ns: 'workflow' }),
value: 'visual' as const,
},
{
Icon: RiCodeLine,
text: t('nodes.triggerSchedule.modeCron', { ns: 'workflow' }),
value: 'cron' as const,
},
]
return (
<SegmentedControl
options={options}
value={mode}
onChange={onChange}
/>
)
}
export default ModeSwitcher

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import DisplayContent from '../display-content'
import { DisplayContent } from '../display-content'
import { PreviewType } from '../types'
describe('variable inspect display content', () => {

View File

@ -2,12 +2,10 @@ import type { VarType } from '../types'
import type { ChunkInfo } from '@/app/components/rag-pipeline/components/chunk-card-list/types'
import type { ParentMode } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { RiBracesLine, RiEyeLine } from '@remixicon/react'
import * as React from 'react'
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/app/components/base/markdown'
import { SegmentedControl } from '@/app/components/base/segmented-control'
import Textarea from '@/app/components/base/textarea'
import { ChunkCardList } from '@/app/components/rag-pipeline/components/chunk-card-list'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
@ -26,11 +24,16 @@ type DisplayContentProps = {
className?: string
}
const DisplayContent = (props: DisplayContentProps) => {
export function DisplayContent(props: DisplayContentProps) {
const { previewType, varType, schemaType, mdString, jsonString, readonly, handleTextChange, handleEditorChange, className } = props
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Code)
const [viewMode, setViewMode] = useState<readonly ViewMode[]>([ViewMode.Code])
const [isFocused, setIsFocused] = useState(false)
const { t } = useTranslation()
const viewOptions = [
{ value: ViewMode.Code, label: t('nodes.templateTransform.code', { ns: 'workflow' }), iconClassName: 'i-ri-braces-line' },
{ value: ViewMode.Preview, label: t('common.preview', { ns: 'workflow' }), iconClassName: 'i-ri-eye-line' },
]
const selectedViewMode = viewMode[0] ?? ViewMode.Code
const chunkType = useMemo(() => {
if (previewType !== PreviewType.Chunks || !schemaType)
@ -65,22 +68,26 @@ const DisplayContent = (props: DisplayContentProps) => {
{schemaType ? `(${schemaType})` : ''}
</div>
)}
<SegmentedControl
options={[
{ value: ViewMode.Code, text: t('nodes.templateTransform.code', { ns: 'workflow' }), Icon: RiBracesLine },
{ value: ViewMode.Preview, text: t('common.preview', { ns: 'workflow' }), Icon: RiEyeLine },
]}
<ToggleGroup<ViewMode>
aria-label={t('common.preview', { ns: 'workflow' })}
value={viewMode}
onChange={setViewMode}
size="small"
padding="with"
activeClassName="text-text-accent-light-mode-only!"
btnClassName="pl-1.5! pr-0.5! gap-[3px]"
className="shrink-0"
/>
onValueChange={setViewMode}
className="shrink-0 rounded-md p-px"
>
{viewOptions.map(({ value, label, iconClassName }) => (
<ToggleGroupItem
key={value}
value={value}
className="h-[22px] gap-[3px] rounded-md p-px pr-0.5 pl-1.5 text-text-tertiary data-pressed:text-text-accent-light-mode-only"
>
<i className={cn('size-4 shrink-0', iconClassName)} aria-hidden="true" />
<span className="p-0.5 pr-1">{label}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="flex flex-1 overflow-auto rounded-b-[10px] pr-1 pl-3">
{viewMode === ViewMode.Code && (
{selectedViewMode === ViewMode.Code && (
previewType === PreviewType.Markdown
? (
<Textarea
@ -105,7 +112,7 @@ const DisplayContent = (props: DisplayContentProps) => {
/>
)
)}
{viewMode === ViewMode.Preview && (
{selectedViewMode === ViewMode.Preview && (
previewType === PreviewType.Markdown
? <Markdown className="grow overflow-auto rounded-lg px-4 py-3" content={(mdString ?? '') as string} />
: (
@ -120,5 +127,3 @@ const DisplayContent = (props: DisplayContentProps) => {
</div>
)
}
export default React.memo(DisplayContent)

View File

@ -11,7 +11,7 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { PreviewMode } from '../../base/features/types'
import BoolValue from '../panel/chat-variable-panel/components/bool-value'
import DisplayContent from './display-content'
import { DisplayContent } from './display-content'
import LargeDataAlert from './large-data-alert'
import { PreviewType } from './types'

View File

@ -28,7 +28,6 @@
@import '../components/base/action-button/index.css';
@import '../components/base/badge/index.css';
@import '../components/base/premium-badge/index.css';
@import '../components/base/segmented-control/index.css';
/* ---------- JS plugins ------------------------------------------------ */
@plugin './plugins/icons.ts';