refactor(ui): enforce single-choice segmented controls (#40726)

This commit is contained in:
yyh 2026-08-13 08:09:10 +00:00 committed by GitHub
parent 7a6f380f2c
commit 36dfc6c216
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 479 additions and 378 deletions

View File

@ -159,7 +159,7 @@ When(
await readFile(getAgentBuilderTestMaterialPath('buildInstruction'), 'utf8')
).trim()
await page.getByRole('button', { exact: true, name: 'Build' }).click()
await page.getByRole('radio', { exact: true, name: 'Build' }).click()
await page.getByPlaceholder('Describe what your agent should do').fill(instruction)
const checkoutResponsePromise = page.waitForResponse(
@ -191,7 +191,7 @@ When(
When('I try to generate an Agent v2 Build draft without a model', async function (this: DifyWorld) {
const page = this.getPage()
await page.getByRole('button', { exact: true, name: 'Build' }).click()
await page.getByRole('radio', { exact: true, name: 'Build' }).click()
await page
.getByPlaceholder('Describe what your agent should do')
.fill('Update the agent instructions for E2E.')

View File

@ -280,7 +280,7 @@ Then(
await expect(variableRow.getByRole('textbox', { name: 'Value' })).toHaveValue(
agentBuilderFixedInputs.envPlainValue,
)
await expect(page.getByRole('button', { name: /^Build$/i })).toBeVisible()
await expect(page.getByRole('radio', { name: /^Build$/i })).toBeVisible()
},
)
@ -318,6 +318,6 @@ Then(
agentBuilderFixedInputs.envAfterInvalidImportKey,
agentBuilderFixedInputs.envAfterInvalidImportValue,
)
await expect(page.getByRole('button', { name: /^Build$/i })).toBeVisible()
await expect(page.getByRole('radio', { name: /^Build$/i })).toBeVisible()
},
)

View File

@ -4232,12 +4232,6 @@
}
},
"web/app/components/workflow/nodes/human-input/components/timeout.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 2
},
"jsx_a11y/no-static-element-interactions": {
"count": 2
},
"no-restricted-imports": {
"count": 1
}
@ -4449,11 +4443,6 @@
"count": 3
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 2

View File

@ -110,9 +110,9 @@ Every icon button must have an `aria-label` or `aria-labelledby`; a tooltip is o
## Segmented control contract
`SegmentedControl` is Dify's design-system primitive for mode, filter, and view selection. It is built on Base UI `ToggleGroup` + `Toggle`, so use `Tabs` instead when the UI needs `tablist` / `tabpanel` semantics.
`SegmentedControl` is Dify's required single-choice primitive for mode, filter, and view selection. It is built on Base UI `RadioGroup` + `Radio`, so `value`, `defaultValue`, and `onValueChange` use the caller's scalar domain value. Provide either `value` or `defaultValue`; an active item cannot be toggled off. Use `Tabs` instead when the UI needs `tablist` / `tabpanel` semantics.
Its value contract follows Base UI: `value`, `defaultValue`, and `onValueChange` use arrays, and single-selection mode may report an empty array when the active item is toggled off.
Keyboard interaction follows the radio-group model: `Tab` enters on the selected item, and an arrow key moves focus and immediately selects the next enabled item.
## Form contract

View File

@ -243,14 +243,14 @@ describe('Pagination primitive', () => {
expect(onPageChange).not.toHaveBeenCalled()
})
it('uses segmented control semantics for page size', async () => {
it('uses required single-choice semantics for page size', async () => {
const { screen, onPageSizeChange } = await renderPagination()
await expect
.element(screen.getByRole('button', { name: '25' }))
.toHaveAttribute('aria-pressed', 'true')
.element(screen.getByRole('radio', { name: '25' }))
.toHaveAttribute('aria-checked', 'true')
await screen.getByRole('button', { name: '50' }).click()
await screen.getByRole('radio', { name: '50' }).click()
expect(onPageSizeChange).toHaveBeenCalledWith(50)
})
@ -273,7 +273,9 @@ describe('Pagination primitive', () => {
await expect
.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 10' }))
.toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toBeInTheDocument()
await expect
.element(screen.getByRole('radiogroup', { name: 'Items per page' }))
.toBeInTheDocument()
})
it('uses a localized action label for editing the page number', async () => {

View File

@ -85,8 +85,8 @@ export const Playground: Story = {
canvas.getByRole('button', { name: 'Edit page number, current page 3 of 200' }),
).toBeVisible()
await userEvent.click(canvas.getByRole('button', { name: '50' }))
await expect(canvas.getByRole('button', { name: '50' })).toHaveAttribute('aria-pressed', 'true')
await userEvent.click(canvas.getByRole('radio', { name: '50' }))
await expect(canvas.getByRole('radio', { name: '50' })).toHaveAttribute('aria-checked', 'true')
},
}

View File

@ -510,23 +510,15 @@ function PaginationPageSize<Value extends number = number>({
{label}
</div>
<SegmentedControl
value={[String(value)]}
value={value}
aria-label={ariaLabel}
onValueChange={(nextValue) => {
const [selectedValue] = nextValue
if (!selectedValue) return
const selectedOption = options.find((option) => String(option) === selectedValue)
if (selectedOption !== undefined) onValueChange(selectedOption)
}}
onValueChange={(value) => onValueChange(value)}
>
{options.map((option) => (
<SegmentedControlItem
<SegmentedControlItem<Value>
key={option}
value={String(option)}
className="min-w-9 data-pressed:text-text-primary"
value={option}
className="min-w-9 data-checked:text-text-primary"
>
{option}
</SegmentedControlItem>

View File

@ -1,88 +1,133 @@
import type { FormEvent } from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { SegmentedControl, SegmentedControlDivider, SegmentedControlItem } from '../index'
describe('SegmentedControl wrappers', () => {
it('renders a segmented control with Base UI pressed state', async () => {
function SegmentedControlTypeExamples() {
return (
<>
<SegmentedControl<number> value={10} onValueChange={() => {}} aria-label="Page size">
<SegmentedControlItem<number> value={10}>10</SegmentedControlItem>
<SegmentedControlItem<number> value={20}>20</SegmentedControlItem>
</SegmentedControl>
{/* @ts-expect-error segmented controls require either value or defaultValue */}
<SegmentedControl aria-label="Missing value">
<SegmentedControlItem value="one">One</SegmentedControlItem>
</SegmentedControl>
</>
)
}
void SegmentedControlTypeExamples
describe('SegmentedControl', () => {
it('exposes a required single choice through radio semantics', async () => {
const screen = await render(
<SegmentedControl defaultValue={['one']} aria-label="View">
<SegmentedControl defaultValue="one" aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>,
)
await expect.element(screen.getByRole('radiogroup', { name: 'View' })).toBeInTheDocument()
await expect
.element(screen.getByRole('button', { name: 'One' }))
.toHaveAttribute('aria-pressed', 'true')
.element(screen.getByRole('radio', { name: 'One' }))
.toHaveAttribute('aria-checked', 'true')
await expect
.element(screen.getByRole('radio', { name: 'Two' }))
.toHaveAttribute('aria-checked', 'false')
})
it('uses single selection by default', async () => {
const screen = await render(
<SegmentedControl defaultValue={['one']} aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>,
)
await screen.getByRole('button', { name: 'Two' }).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 () => {
it('updates an uncontrolled selection without allowing the selected item to be cleared', async () => {
const onValueChange = vi.fn()
const screen = await render(
<SegmentedControl value={['one']} onValueChange={onValueChange} aria-label="View">
<SegmentedControl defaultValue="one" onValueChange={onValueChange} aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>,
)
await screen.getByRole('button', { name: 'Two' }).click()
await screen.getByRole('radio', { name: 'One' }).click()
expect(onValueChange).toHaveBeenCalledWith(['two'], expect.anything())
expect(onValueChange).not.toHaveBeenCalled()
await expect
.element(screen.getByRole('button', { name: 'One' }))
.toHaveAttribute('aria-pressed', 'true')
.element(screen.getByRole('radio', { name: 'One' }))
.toHaveAttribute('aria-checked', 'true')
await screen.getByRole('radio', { name: 'Two' }).click()
expect(onValueChange).toHaveBeenCalledWith('two', expect.anything())
await expect
.element(screen.getByRole('radio', { name: 'Two' }))
.toHaveAttribute('aria-checked', 'true')
})
it('preserves Base UI empty-array behavior when a single selected item is toggled off', async () => {
it('leaves a controlled selection to its caller', async () => {
const onValueChange = vi.fn()
const screen = await render(
<SegmentedControl value={['one']} onValueChange={onValueChange} aria-label="View">
<SegmentedControl value="one" onValueChange={onValueChange} aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>,
)
await screen.getByRole('button', { name: 'One' }).click()
await screen.getByRole('radio', { name: 'Two' }).click()
expect(onValueChange).toHaveBeenCalledWith([], expect.anything())
expect(onValueChange).toHaveBeenCalledWith('two', expect.anything())
await expect
.element(screen.getByRole('button', { name: 'One' }))
.toHaveAttribute('aria-pressed', 'true')
.element(screen.getByRole('radio', { name: 'One' }))
.toHaveAttribute('aria-checked', 'true')
})
it('forwards disabled and className to composable parts', async () => {
it('selects the next enabled item with an arrow key', async () => {
const screen = await render(
<SegmentedControl defaultValue={['one']} aria-label="View" className="custom-group">
<SegmentedControlItem value="one" className="custom-item">
One
<SegmentedControl defaultValue="one" aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two" disabled>
Two
</SegmentedControlItem>
<SegmentedControlDivider className="custom-divider" data-testid="divider" />
<SegmentedControlItem value="three">Three</SegmentedControlItem>
</SegmentedControl>,
)
const one = screen.getByRole('radio', { name: 'One' })
const three = screen.getByRole('radio', { name: 'Three' })
;(one.element() as HTMLElement).focus()
await userEvent.keyboard('{ArrowRight}')
await expect.element(three).toHaveFocus()
await expect.element(three).toHaveAttribute('aria-checked', 'true')
})
it('uses non-submitting native buttons for its items', async () => {
const onSubmit = vi.fn((event: FormEvent) => event.preventDefault())
const screen = await render(
<form onSubmit={onSubmit}>
<SegmentedControl defaultValue="one" aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlItem value="two">Two</SegmentedControlItem>
</SegmentedControl>
</form>,
)
await screen.getByRole('radio', { name: 'Two' }).click()
expect(onSubmit).not.toHaveBeenCalled()
})
it('keeps disabled item semantics and a decorative divider', async () => {
const screen = await render(
<SegmentedControl defaultValue="one" aria-label="View">
<SegmentedControlItem value="one">One</SegmentedControlItem>
<SegmentedControlDivider data-testid="divider" />
<SegmentedControlItem value="two" disabled>
Two
</SegmentedControlItem>
</SegmentedControl>,
)
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')
await expect.element(screen.getByRole('radio', { name: 'Two' })).toBeDisabled()
await expect.element(screen.getByTestId('divider')).toHaveAttribute('aria-hidden', 'true')
})
})

View File

@ -10,11 +10,14 @@ const meta = {
docs: {
description: {
component:
'Segmented control built on Base UI ToggleGroup and Toggle. Use it for mode, filter, and view selection that does not need tabpanel semantics.',
'Required single-choice segmented control built on Base UI RadioGroup and Radio. Use it for mode, filter, and view selection that does not need tabpanel semantics.',
},
},
},
tags: ['autodocs'],
args: {
defaultValue: 'one',
},
} satisfies Meta<typeof SegmentedControl>
export default meta
@ -44,7 +47,7 @@ function SegmentedControlExample({
}: SegmentedControlProps) {
return (
<SegmentedControl
defaultValue={[defaultValue]}
defaultValue={defaultValue}
aria-label="Segmented control"
className={noPadding ? 'rounded-lg border-[0.5px] border-divider-subtle p-0' : undefined}
>
@ -113,7 +116,7 @@ export const DesignSpec: Story = {
export const DataAttributeStates: Story = {
render: () => (
<div className="flex flex-col gap-5">
<SegmentedControl defaultValue={['active']} aria-label="Basic states">
<SegmentedControl defaultValue="active" aria-label="Basic states">
<SegmentedControlItem value="default">
<Item />
</SegmentedControlItem>
@ -125,39 +128,27 @@ export const DataAttributeStates: Story = {
</SegmentedControlItem>
</SegmentedControl>
<SegmentedControl defaultValue={['accent-light']} aria-label="Active states">
<SegmentedControl defaultValue="accent-light" aria-label="Active states">
<SegmentedControlItem value="accent-light">
<Item />
</SegmentedControlItem>
<SegmentedControlItem value="neutral" className="data-pressed:text-text-primary">
<SegmentedControlItem value="neutral" className="data-checked:text-text-primary">
<Item />
</SegmentedControlItem>
<SegmentedControlItem
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"
className="data-checked:border-components-segmented-control-item-active-accent-border data-checked:bg-components-segmented-control-item-active-accent-bg data-checked:text-text-accent"
>
<Item />
</SegmentedControlItem>
</SegmentedControl>
<SegmentedControl defaultValue={['one', 'three']} multiple aria-label="Multiple selection">
<SegmentedControlItem value="one">
<Item />
</SegmentedControlItem>
<SegmentedControlItem value="two">
<Item />
</SegmentedControlItem>
<SegmentedControlItem value="three">
<Item />
</SegmentedControlItem>
</SegmentedControl>
</div>
),
parameters: {
docs: {
description: {
story:
'`SegmentedControlItem` gets `data-pressed` and `data-disabled` from Base UI Toggle. Accent, neutral, and multiple-selection examples are composed through props and className.',
'`SegmentedControlItem` gets `data-checked` and `data-disabled` from Base UI Radio. Accent and neutral states are composed through props and className.',
},
},
},

View File

@ -1,25 +1,33 @@
'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 { Radio as BaseRadioNS } from '@base-ui/react/radio'
import type { RadioGroup as BaseRadioGroupNS } from '@base-ui/react/radio-group'
import type * as React from 'react'
import { Toggle as BaseToggle } from '@base-ui/react/toggle'
import { ToggleGroup as BaseToggleGroup } from '@base-ui/react/toggle-group'
import { Radio as BaseRadio } from '@base-ui/react/radio'
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'
import { cn } from '../cn'
type SegmentedControlProps<Value extends string = string> = Omit<
BaseToggleGroupNS.Props<Value>,
'className'
> & {
className?: string
}
type SegmentedControlSelectionProps<Value> =
| {
value: Value
defaultValue?: never
}
| {
value?: never
defaultValue: Value
}
function SegmentedControl<Value extends string = string>({
className,
...props
}: SegmentedControlProps<Value>) {
type SegmentedControlProps<Value = string> = Omit<
BaseRadioGroupNS.Props<Value>,
'className' | 'defaultValue' | 'value'
> &
SegmentedControlSelectionProps<Value> & {
className?: string
}
function SegmentedControl<Value = string>({ className, ...props }: SegmentedControlProps<Value>) {
return (
<BaseToggleGroup
<BaseRadioGroup<Value>
className={cn(
'inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5',
className,
@ -29,21 +37,25 @@ function SegmentedControl<Value extends string = string>({
)
}
type SegmentedControlItemProps<Value extends string = string> = Omit<
BaseToggleNS.Props<Value>,
type SegmentedControlItemProps<Value = string> = Omit<
BaseRadioNS.Root.Props<Value>,
'className'
> & {
className?: string
}
function SegmentedControlItem<Value extends string = string>({
function SegmentedControlItem<Value = string>({
className,
nativeButton = true,
render = <button type="button" />,
...props
}: SegmentedControlItemProps<Value>) {
return (
<BaseToggle
<BaseRadio.Root<Value>
nativeButton={nativeButton}
render={render}
className={cn(
'relative flex h-7 min-w-0 touch-manipulation items-center justify-center gap-0.5 overflow-hidden rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium whitespace-nowrap text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden 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 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 motion-reduce:transition-none',
'relative flex h-7 min-w-0 touch-manipulation items-center justify-center gap-0.5 overflow-hidden rounded-lg border-[0.5px] border-transparent px-2 py-1 system-sm-medium whitespace-nowrap text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden data-checked:border-components-segmented-control-item-active-border data-checked:bg-components-segmented-control-item-active-bg data-checked:text-text-accent-light-mode-only data-checked:shadow-xs data-checked: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}

View File

@ -18,19 +18,19 @@ describe('TabSliderNew', () => {
/>,
)
expect(screen.getByRole('group', { name: 'Tool categories' })).toBeInTheDocument()
expect(screen.getByRole('radiogroup', { name: 'Tool categories' })).toBeInTheDocument()
const allButton = screen.getByRole('button', { name: 'All' })
const activeButton = screen.getByRole('button', { name: 'Active' })
const allOption = screen.getByRole('radio', { name: 'All' })
const activeOption = screen.getByRole('radio', { name: 'Active' })
expect(allButton).toHaveAttribute('aria-pressed', 'true')
expect(activeButton).toHaveAttribute('aria-pressed', 'false')
expect(allOption).toHaveAttribute('aria-checked', 'true')
expect(activeOption).toHaveAttribute('aria-checked', 'false')
await user.click(allButton)
await user.click(allOption)
expect(onChange).not.toHaveBeenCalled()
activeButton.focus()
await user.keyboard('{Enter}')
allOption.focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith('active')
})

View File

@ -19,11 +19,8 @@ const TabSliderNew: FC<TabSliderProps> = ({ ariaLabel, className, value, onChang
<SegmentedControl
aria-label={ariaLabel}
data-testid="tab-slider-new"
value={[value]}
onValueChange={(nextValues) => {
const nextValue = nextValues[0]
if (nextValue && nextValue !== value) onChange(nextValue)
}}
value={value}
onValueChange={(value) => onChange(value)}
className={cn(className, 'relative flex gap-0 rounded-none bg-transparent p-0')}
>
{options.map((option) => (
@ -31,7 +28,7 @@ const TabSliderNew: FC<TabSliderProps> = ({ ariaLabel, className, value, onChang
key={option.value}
value={option.value}
data-testid={`tab-item-${option.value}`}
className="mr-1 h-8 justify-start gap-0 overflow-visible px-3 py-1.75 text-start text-[13px] leading-4.5 font-medium whitespace-normal text-text-tertiary transition-none hover:bg-state-base-hover hover:text-text-tertiary data-pressed:border-components-main-nav-nav-button-border data-pressed:bg-state-base-hover data-pressed:text-components-main-nav-nav-button-text-active data-pressed:shadow-xs"
className="mr-1 h-8 justify-start gap-0 overflow-visible px-3 py-1.75 text-start text-[13px] leading-4.5 font-medium whitespace-normal text-text-tertiary transition-none hover:bg-state-base-hover hover:text-text-tertiary data-checked:border-components-main-nav-nav-button-border data-checked:bg-state-base-hover data-checked:text-components-main-nav-nav-button-text-active data-checked:shadow-xs"
>
{option.icon}
{option.text}

View File

@ -523,8 +523,10 @@ describe('Completed Component', () => {
it('should expose page-size controls', () => {
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByRole('group', { name: 'common.pagination.perPage' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '10' })).toHaveAttribute('aria-pressed', 'true')
expect(
screen.getByRole('radiogroup', { name: 'common.pagination.perPage' }),
).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '10' })).toHaveAttribute('aria-checked', 'true')
})
})

View File

@ -284,17 +284,15 @@ describe('List', () => {
renderWithNuqs(<List />)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.legacy' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.new' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.legacy' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.new' })).toBeInTheDocument()
})
it('should keep the legacy query active without requesting KnowledgeFS when disabled', async () => {
renderWithNuqs(<List />, { searchParams: '?view=new' })
expect(
screen.queryByRole('button', { name: 'dataset.newKnowledge.new' }),
screen.queryByRole('radio', { name: 'dataset.newKnowledge.new' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('region', { name: 'dataset.newKnowledge.new' }),
@ -312,7 +310,7 @@ describe('List', () => {
mockConsoleState.knowledgeFsEnabled = true
const { onUrlUpdate } = renderWithNuqs(<List />)
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.new' }))
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.new' }))
expect(
await screen.findByRole('region', { name: 'dataset.newKnowledge.new' }),
@ -330,13 +328,13 @@ describe('List', () => {
await user.click(screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }))
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.new' }))
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.new' }))
expect(screen.queryByTestId('external-api-panel')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }))
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.legacy' }))
await user.click(screen.getByRole('radio', { name: 'dataset.newKnowledge.legacy' }))
expect(screen.queryByTestId('external-api-panel')).not.toBeInTheDocument()
})
@ -346,8 +344,8 @@ describe('List', () => {
renderWithNuqs(<List />, { searchParams: '?view=new' })
expect(screen.getByRole('region', { name: 'dataset.newKnowledge.new' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'dataset.newKnowledge.new' })).toHaveAttribute(
'aria-pressed',
expect(screen.getByRole('radio', { name: 'dataset.newKnowledge.new' })).toHaveAttribute(
'aria-checked',
'true',
)
})

View File

@ -273,11 +273,8 @@ const MCPModalContent: FC<MCPModalContentProps> = ({ data, onConfirm, onHide })
{/* Auth Method Tabs */}
<SegmentedControl<MCPAuthMethod>
value={[state.authMethod]}
onValueChange={(nextValue) => {
const nextAuthMethod = nextValue[0]
if (nextAuthMethod) actions.setAuthMethod(nextAuthMethod)
}}
value={state.authMethod}
onValueChange={actions.setAuthMethod}
aria-label={t(($) => $['mcp.modal.authentication'], { ns: 'tools' })}
className="w-full"
>

View File

@ -5,21 +5,21 @@ import { ViewType } from '../types'
import ViewTypeSelect from '../view-type-select'
describe('ViewTypeSelect', () => {
it('exposes the current view through named toggle buttons', () => {
it('exposes the current view as a required single choice', () => {
render(<ViewTypeSelect viewType={ViewType.flat} onChange={vi.fn()} />)
expect(screen.getByRole('group', { name: 'common.operation.view' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.tabs.listView' })).toHaveAttribute(
'aria-pressed',
expect(screen.getByRole('radiogroup', { name: 'common.operation.view' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'workflow.tabs.listView' })).toHaveAttribute(
'aria-checked',
'true',
)
expect(screen.getByRole('button', { name: 'workflow.tabs.treeView' })).toHaveAttribute(
'aria-pressed',
expect(screen.getByRole('radio', { name: 'workflow.tabs.treeView' })).toHaveAttribute(
'aria-checked',
'false',
)
})
it('changes the view with roving focus and keyboard activation', async () => {
it('changes the view as arrow-key focus moves', async () => {
const user = userEvent.setup()
function ViewTypeSelectHarness() {
@ -29,8 +29,8 @@ describe('ViewTypeSelect', () => {
render(<ViewTypeSelectHarness />)
const flatView = screen.getByRole('button', { name: 'workflow.tabs.listView' })
const treeView = screen.getByRole('button', { name: 'workflow.tabs.treeView' })
const flatView = screen.getByRole('radio', { name: 'workflow.tabs.listView' })
const treeView = screen.getByRole('radio', { name: 'workflow.tabs.treeView' })
await user.tab()
expect(flatView).toHaveFocus()
@ -38,9 +38,7 @@ describe('ViewTypeSelect', () => {
await user.keyboard('{ArrowRight}')
expect(treeView).toHaveFocus()
await user.keyboard(' ')
expect(flatView).toHaveAttribute('aria-pressed', 'false')
expect(treeView).toHaveAttribute('aria-pressed', 'true')
expect(flatView).toHaveAttribute('aria-checked', 'false')
expect(treeView).toHaveAttribute('aria-checked', 'true')
})
})

View File

@ -12,19 +12,12 @@ type Props = Readonly<{
function ViewTypeSelect({ viewType, onChange }: Props) {
const { t } = useTranslation()
const handleValueChange = (value: ViewType[]) => {
const nextViewType = value[0]
if (!nextViewType || nextViewType === viewType) return
onChange(nextViewType)
}
return (
<SegmentedControl<ViewType>
value={[viewType]}
value={viewType}
aria-label={t(($) => $['operation.view'], { ns: 'common' })}
className="gap-0 rounded-lg p-px"
onValueChange={handleValueChange}
onValueChange={(value) => onChange(value)}
>
<SegmentedControlItem<ViewType>
value={ViewType.flat}

View File

@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { VarType } from '@/app/components/workflow/nodes/tool/types'
import FormInputTypeSwitch from '../form-input-type-switch'
describe('FormInputTypeSwitch', () => {
it('changes the required input type selection', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<FormInputTypeSwitch value={VarType.variable} onChange={onChange} />)
expect(
screen.getByRole('radio', { name: 'workflow.nodes.common.typeSwitch.variable' }),
).toHaveAttribute('aria-checked', 'true')
await user.click(screen.getByRole('radio', { name: 'workflow.nodes.common.typeSwitch.input' }))
expect(onChange).toHaveBeenCalledWith(VarType.constant)
})
it('disables both input type options when read-only', () => {
render(<FormInputTypeSwitch value={VarType.constant} onChange={vi.fn()} readonly />)
expect(
screen.getByRole('radio', { name: 'workflow.nodes.common.typeSwitch.variable' }),
).toBeDisabled()
expect(
screen.getByRole('radio', { name: 'workflow.nodes.common.typeSwitch.input' }),
).toBeDisabled()
})
})

View File

@ -1,8 +1,8 @@
'use client'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { VarType } from '@/app/components/workflow/nodes/tool/types'
type Props = Readonly<{
@ -11,74 +11,58 @@ type Props = Readonly<{
readonly?: boolean
}>
type TypeOptionProps = {
children: ReactNode
label: string
selected: boolean
value: VarType
}
const optionClassName =
'cursor-pointer border-0 px-2.5 py-1.5 text-text-tertiary transition-none hover:text-text-tertiary data-checked:border-0 data-checked:text-text-secondary data-checked:shadow-black/5 data-checked:hover:bg-components-segmented-control-item-active-bg data-checked:hover:text-text-secondary data-disabled:text-text-tertiary data-disabled:data-checked:bg-components-segmented-control-item-active-bg data-disabled:data-checked:text-text-secondary data-disabled:data-checked:shadow-xs data-disabled:data-checked:shadow-black/5'
function TypeOption({ children, label, selected, value }: TypeOptionProps) {
const option = (
<SegmentedControlItem<VarType> value={value} aria-label={label} className={optionClassName}>
{children}
</SegmentedControlItem>
)
if (selected) return option
return (
<Tooltip>
<TooltipTrigger render={option} />
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
const FormInputTypeSwitch: FC<Props> = ({ value, onChange, readonly = false }) => {
const { t } = useTranslation()
const variableLabel = t(($) => $['nodes.common.typeSwitch.variable'], { ns: 'workflow' })
const inputLabel = t(($) => $['nodes.common.typeSwitch.input'], { ns: 'workflow' })
return (
<div className="inline-flex h-8 shrink-0 gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5">
{value === VarType.variable ? (
<button
type="button"
aria-label={variableLabel}
aria-pressed={true}
disabled={readonly}
className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg disabled:cursor-not-allowed"
onClick={() => onChange(VarType.variable)}
>
<Variable02 className="size-4" />
</button>
) : (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={variableLabel}
aria-pressed={false}
disabled={readonly}
className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed"
onClick={() => onChange(VarType.variable)}
>
<Variable02 className="size-4" />
</button>
}
/>
<TooltipContent>{variableLabel}</TooltipContent>
</Tooltip>
)}
{value === VarType.constant ? (
<button
type="button"
aria-label={inputLabel}
aria-pressed={true}
disabled={readonly}
className="cursor-pointer rounded-lg bg-components-segmented-control-item-active-bg px-2.5 py-1.5 text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg disabled:cursor-not-allowed"
onClick={() => onChange(VarType.constant)}
>
<span aria-hidden className="i-ri-edit-line size-4" />
</button>
) : (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={inputLabel}
aria-pressed={false}
disabled={readonly}
className="cursor-pointer rounded-lg px-2.5 py-1.5 text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed"
onClick={() => onChange(VarType.constant)}
>
<span aria-hidden className="i-ri-edit-line size-4" />
</button>
}
/>
<TooltipContent>{inputLabel}</TooltipContent>
</Tooltip>
)}
</div>
<SegmentedControl<VarType>
value={value}
onValueChange={(value) => onChange(value)}
disabled={readonly}
aria-label={`${variableLabel}, ${inputLabel}`}
className="h-8 shrink-0"
>
<TypeOption
value={VarType.variable}
label={variableLabel}
selected={value === VarType.variable}
>
<span aria-hidden className="i-custom-vender-solid-development-variable-02 size-4" />
</TypeOption>
<TypeOption value={VarType.constant} label={inputLabel} selected={value === VarType.constant}>
<span aria-hidden className="i-ri-edit-line size-4" />
</TypeOption>
</SegmentedControl>
)
}
export default FormInputTypeSwitch

View File

@ -579,7 +579,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
const user = userEvent.setup()
renderWorkspace()
const previewButton = await screen.findByRole('button', {
const previewButton = await screen.findByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.preview',
})
expect(previewButton).toBeEnabled()
@ -598,7 +598,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
expect(mocks.saveBuildDraft).not.toHaveBeenCalled()
await user.click(
screen.getByRole('button', {
screen.getByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.build',
}),
)
@ -618,7 +618,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
})
await user.click(
await screen.findByRole('button', {
await screen.findByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.preview',
}),
)
@ -650,7 +650,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('preview:none')
await user.click(
screen.getByRole('button', {
screen.getByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.build',
}),
)
@ -670,7 +670,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
)
await user.click(
screen.getByRole('button', {
screen.getByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.preview',
}),
)
@ -721,7 +721,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
await waitFor(() => expect(mocks.saveBuildDraft).toHaveBeenCalledTimes(1))
await user.click(
screen.getByRole('button', {
screen.getByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.preview',
}),
)
@ -763,7 +763,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
if (!completeBuildConversation) throw new Error('Expected a Build completion callback.')
await user.click(
screen.getByRole('button', {
screen.getByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.preview',
}),
)
@ -775,7 +775,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
await screen.findByRole('region', { name: 'preview-chat' })
await user.click(
screen.getByRole('button', {
screen.getByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.build',
}),
)
@ -809,7 +809,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
renderWorkspace()
await user.click(
await screen.findByRole('button', {
await screen.findByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.preview',
}),
)
@ -832,7 +832,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
renderWorkspace({ deploymentEdition: 'COMMUNITY' })
expect(
await screen.findByRole('button', {
await screen.findByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.preview',
}),
).toBeDisabled()

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { withSelectorKey } from '@/test/i18n-mock'
import TimeoutInput from '../timeout'
@ -34,17 +35,19 @@ describe('TimeoutInput', () => {
})
})
it('should update the numeric timeout value and switch units', () => {
it('should update the numeric timeout value and switch units', async () => {
const user = userEvent.setup()
render(<TimeoutInput timeout={3} unit="day" onChange={onChange} />)
fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: '12' } })
fireEvent.click(screen.getByText('nodes.humanInput.timeout.hours'))
await user.click(screen.getByRole('radio', { name: 'nodes.humanInput.timeout.hours' }))
expect(onChange).toHaveBeenNthCalledWith(1, { timeout: 12, unit: 'day' })
expect(onChange).toHaveBeenNthCalledWith(2, { timeout: 3, unit: 'hour' })
})
it('should fall back to 1 on invalid input and stay read-only when disabled', () => {
it('should fall back to 1 on invalid input and stay read-only when disabled', async () => {
const user = userEvent.setup()
const { rerender } = render(<TimeoutInput timeout={5} unit="hour" onChange={onChange} />)
fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: 'abc' } })
@ -52,8 +55,9 @@ describe('TimeoutInput', () => {
rerender(<TimeoutInput timeout={5} unit="hour" onChange={onChange} readonly />)
fireEvent.click(screen.getByText('nodes.humanInput.timeout.days'))
await user.click(screen.getByRole('radio', { name: 'nodes.humanInput.timeout.days' }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('timeout-input')).toBeDisabled()
expect(screen.getByRole('radio', { name: 'nodes.humanInput.timeout.days' })).toBeDisabled()
})
})

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@ -13,8 +13,14 @@ type Props = Readonly<{
readonly?: boolean
}>
const unitOptionClassName =
'cursor-pointer border-0 text-text-tertiary transition-none data-checked:border-0 data-checked:shadow-sm data-checked:shadow-black/10 data-checked:hover:bg-components-segmented-control-item-active-bg data-checked:hover:text-text-accent-light-mode-only data-disabled:cursor-default data-disabled:text-text-tertiary data-disabled:data-checked:bg-components-segmented-control-item-active-bg data-disabled:data-checked:text-text-accent-light-mode-only data-disabled:data-checked:shadow-sm data-disabled:data-checked:shadow-black/10'
const TimeoutInput: FC<Props> = ({ timeout, unit, onChange, readonly }) => {
const { t } = useTranslation()
const timeoutLabel = t(($) => $[`${i18nPrefix}.timeout.title`], { ns: 'workflow' })
const daysLabel = t(($) => $[`${i18nPrefix}.timeout.days`], { ns: 'workflow' })
const hoursLabel = t(($) => $[`${i18nPrefix}.timeout.hours`], { ns: 'workflow' })
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
@ -31,40 +37,20 @@ const TimeoutInput: FC<Props> = ({ timeout, unit, onChange, readonly }) => {
onChange={handleValueChange}
disabled={readonly}
/>
<div className="flex items-center gap-0.5 rounded-[10px] bg-components-segmented-control-bg-normal p-0.5">
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary',
!readonly && 'cursor-pointer hover:bg-state-base-hover hover:text-text-secondary',
unit === 'day' &&
'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm',
!readonly &&
unit === 'day' &&
'hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => !readonly && onChange({ timeout, unit: 'day' })}
>
<div className="p-0.5 system-sm-medium">
{t(($) => $[`${i18nPrefix}.timeout.days`], { ns: 'workflow' })}
</div>
</div>
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary',
!readonly && 'cursor-pointer hover:bg-state-base-hover hover:text-text-secondary',
unit === 'hour' &&
'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm',
!readonly &&
unit === 'hour' &&
'hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => !readonly && onChange({ timeout, unit: 'hour' })}
>
<div className="p-0.5 system-sm-medium">
{t(($) => $[`${i18nPrefix}.timeout.hours`], { ns: 'workflow' })}
</div>
</div>
</div>
<SegmentedControl<'day' | 'hour'>
value={unit}
onValueChange={(unit) => onChange({ timeout, unit })}
disabled={readonly}
aria-label={timeoutLabel}
className="gap-0.5"
>
<SegmentedControlItem value="day" className={unitOptionClassName}>
<div className="p-0.5 system-sm-medium">{daysLabel}</div>
</SegmentedControlItem>
<SegmentedControlItem value="hour" className={unitOptionClassName}>
<div className="p-0.5 system-sm-medium">{hoursLabel}</div>
</SegmentedControlItem>
</SegmentedControl>
</div>
)
}

View File

@ -256,7 +256,7 @@ describe('LLM Panel', () => {
renderPanel()
await user.click(
screen.getByRole('button', { name: 'workflow.nodes.common.typeSwitch.variable' }),
screen.getByRole('radio', { name: 'workflow.nodes.common.typeSwitch.variable' }),
)
expect(handleModelSourceChange).toHaveBeenCalledWith(true)
})
@ -265,10 +265,10 @@ describe('LLM Panel', () => {
renderPanel(undefined, FlowType.snippet)
expect(
screen.queryByRole('button', { name: 'workflow.nodes.common.typeSwitch.variable' }),
screen.queryByRole('radio', { name: 'workflow.nodes.common.typeSwitch.variable' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'workflow.nodes.common.typeSwitch.input' }),
screen.queryByRole('radio', { name: 'workflow.nodes.common.typeSwitch.input' }),
).not.toBeInTheDocument()
})
@ -284,7 +284,7 @@ describe('LLM Panel', () => {
)
renderPanel({ model_selector: ['env', 'shared_model'] }, FlowType.snippet)
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.typeSwitch.input' }))
await user.click(screen.getByRole('radio', { name: 'workflow.nodes.common.typeSwitch.input' }))
expect(handleModelSourceChange).toHaveBeenCalledWith(false)
})
@ -378,7 +378,7 @@ describe('LLM Panel', () => {
await user.click(screen.getByText('for_summarize'))
expect(handleModelSelectorChange).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.typeSwitch.input' }))
await user.click(screen.getByRole('radio', { name: 'workflow.nodes.common.typeSwitch.input' }))
resolveParameters({ params: {}, removedDetails: {} })
await waitFor(() => {

View File

@ -0,0 +1,90 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { JsonSchemaConfig } from '../json-schema-config'
const emit = vi.fn()
const visualEditorState = {
advancedEditing: false,
isAddingNewField: false,
setAdvancedEditing: vi.fn(),
setHoveringProperty: vi.fn(),
setIsAddingNewField: vi.fn(),
}
vi.mock('../visual-editor/context', () => ({
MittProvider: ({ children }: { children: ReactNode }) => children,
VisualEditorContextProvider: ({ children }: { children: ReactNode }) => children,
useMittContext: () => ({ emit }),
}))
vi.mock('../visual-editor/store', () => ({
useVisualEditorStore: (selector: (state: typeof visualEditorState) => unknown) =>
selector(visualEditorState),
}))
vi.mock('../visual-editor', () => ({
default: () => <div>Visual editor panel</div>,
}))
vi.mock('../schema-editor', () => ({
default: ({ schema, onUpdate }: { schema: string; onUpdate: (schema: string) => void }) => (
<textarea
aria-label="JSON schema editor"
value={schema}
onChange={(event) => onUpdate(event.target.value)}
/>
),
}))
vi.mock('../error-message', () => ({
default: ({ message }: { message: string }) => <div role="alert">{message}</div>,
}))
vi.mock('../json-schema-generator', () => ({
default: () => null,
}))
vi.mock('../json-importer', () => ({
default: () => null,
}))
describe('JsonSchemaConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('moves tab focus without activating a different editor', async () => {
const user = userEvent.setup()
render(<JsonSchemaConfig onSave={vi.fn()} onClose={vi.fn()} />)
const visualTab = screen.getByRole('tab', { name: 'Visual Editor' })
const jsonTab = screen.getByRole('tab', { name: 'JSON Schema' })
visualTab.focus()
await user.keyboard('{ArrowRight}')
expect(jsonTab).toHaveFocus()
expect(visualTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('Visual editor panel')).toBeInTheDocument()
})
it('keeps the JSON editor active when its value cannot be validated', async () => {
const user = userEvent.setup()
render(<JsonSchemaConfig onSave={vi.fn()} onClose={vi.fn()} />)
await user.click(screen.getByRole('tab', { name: 'JSON Schema' }))
const editor = screen.getByRole('textbox', { name: 'JSON schema editor' })
await user.clear(editor)
await user.type(editor, 'invalid json')
await user.click(screen.getByRole('tab', { name: 'Visual Editor' }))
expect(screen.getByRole('tab', { name: 'JSON Schema' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(editor).toBeInTheDocument()
expect(screen.getByRole('alert')).toBeInTheDocument()
})
})

View File

@ -1,7 +1,7 @@
import type { SchemaRoot } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -29,10 +29,7 @@ type JsonSchemaConfigProps = {
onClose: () => void
}
enum SchemaView {
VisualEditor = 'visualEditor',
JsonSchema = 'jsonSchema',
}
type SchemaView = 'visualEditor' | 'jsonSchema'
type IconProps = {
className?: string
@ -47,9 +44,13 @@ function BracesIcon({ className }: IconProps) {
}
const SCHEMA_VIEW_OPTIONS = [
{ Icon: TimelineViewIcon, text: 'Visual Editor', value: SchemaView.VisualEditor },
{ Icon: BracesIcon, text: 'JSON Schema', value: SchemaView.JsonSchema },
]
{ Icon: TimelineViewIcon, text: 'Visual Editor', value: 'visualEditor' },
{ Icon: BracesIcon, text: 'JSON Schema', value: 'jsonSchema' },
] satisfies Array<{ Icon: typeof TimelineViewIcon; text: string; value: SchemaView }>
function isSchemaView(value: unknown): value is SchemaView {
return value === 'visualEditor' || value === 'jsonSchema'
}
const DEFAULT_SCHEMA: SchemaRoot = {
type: Type.object,
@ -60,9 +61,7 @@ const DEFAULT_SCHEMA: SchemaRoot = {
function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaConfigProps) {
const { t } = useTranslation()
const [selectedSchemaViews, setSelectedSchemaViews] = useState<readonly SchemaView[]>([
SchemaView.VisualEditor,
])
const [selectedSchemaView, setSelectedSchemaView] = useState<SchemaView>('visualEditor')
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2))
const [btnWidth, setBtnWidth] = useState(0)
@ -74,32 +73,31 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
const setIsAddingNewField = useVisualEditorStore((state) => state.setIsAddingNewField)
const setHoveringProperty = useVisualEditorStore((state) => state.setHoveringProperty)
const { emit } = useMittContext()
const selectedSchemaView = selectedSchemaViews[0] ?? SchemaView.VisualEditor
function updateBtnWidth(width: number) {
setBtnWidth(width + 32)
}
function handleSchemaViewChange(value: SchemaView) {
if (selectedSchemaView === value) return
if (selectedSchemaView === SchemaView.JsonSchema) {
if (selectedSchemaView === value) return true
if (selectedSchemaView === 'jsonSchema') {
try {
const schema = JSON.parse(json)
setParseError(null)
const result = preValidateSchema(schema)
if (!result.success) {
setValidationError(result.error.message)
return
return false
}
const schemaDepth = checkJsonSchemaDepth(schema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return
return false
}
const validationErrors = validateSchemaAgainstDraft7(schema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return
return false
}
setJsonSchema(schema)
setValidationError('')
@ -107,9 +105,9 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
setValidationError('')
if (error instanceof Error) setParseError(error)
else setParseError(new Error('Invalid JSON'))
return
return false
}
} else if (selectedSchemaView === SchemaView.VisualEditor) {
} else if (selectedSchemaView === 'visualEditor') {
if (advancedEditing || isAddingNewField)
emit('quitEditing', {
callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)),
@ -117,19 +115,19 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
else setJson(JSON.stringify(jsonSchema, null, 2))
}
setSelectedSchemaViews([value])
setSelectedSchemaView(value)
return true
}
function handleApplySchema(schema: SchemaRoot) {
if (selectedSchemaView === SchemaView.VisualEditor) setJsonSchema(schema)
else if (selectedSchemaView === SchemaView.JsonSchema) setJson(JSON.stringify(schema, null, 2))
if (selectedSchemaView === 'visualEditor') setJsonSchema(schema)
else if (selectedSchemaView === 'jsonSchema') setJson(JSON.stringify(schema, null, 2))
}
function handleSubmit(schema: Record<string, unknown>) {
const jsonSchema = jsonToSchema(schema) as SchemaRoot
if (selectedSchemaView === SchemaView.VisualEditor) setJsonSchema(jsonSchema)
else if (selectedSchemaView === SchemaView.JsonSchema)
setJson(JSON.stringify(jsonSchema, null, 2))
if (selectedSchemaView === 'visualEditor') setJsonSchema(jsonSchema)
else if (selectedSchemaView === 'jsonSchema') setJson(JSON.stringify(jsonSchema, null, 2))
}
function handleVisualEditorUpdate(schema: SchemaRoot) {
@ -141,7 +139,7 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
}
function handleResetDefaults() {
if (selectedSchemaView === SchemaView.VisualEditor) {
if (selectedSchemaView === 'visualEditor') {
setHoveringProperty(null)
if (advancedEditing) setAdvancedEditing(false)
if (isAddingNewField) setIsAddingNewField(false)
@ -156,7 +154,7 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
function handleSave() {
let schema = jsonSchema
if (selectedSchemaView === SchemaView.JsonSchema) {
if (selectedSchemaView === 'jsonSchema') {
try {
schema = JSON.parse(json)
setParseError(null)
@ -183,7 +181,7 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
else setParseError(new Error('Invalid JSON'))
return
}
} else if (selectedSchemaView === SchemaView.VisualEditor) {
} else if (selectedSchemaView === 'visualEditor') {
if (advancedEditing || isAddingNewField) {
toast.warning(
t(($) => $['nodes.llm.jsonSchema.warningTips.saveSchema'], { ns: 'workflow' }),
@ -196,7 +194,13 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
}
return (
<div className="flex h-full flex-col">
<Tabs
value={selectedSchemaView}
onValueChange={(value, eventDetails) => {
if (!isSchemaView(value) || !handleSchemaViewChange(value)) eventDetails.cancel()
}}
className="flex h-full flex-col"
>
{/* Header */}
<div className="relative flex p-6 pr-14 pb-3">
<div className="grow truncate title-2xl-semi-bold text-text-primary">
@ -212,21 +216,21 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
</button>
</div>
<div className="flex items-center justify-between px-6 py-2">
<SegmentedControl<SchemaView>
<TabsList
aria-label={t(($) => $['nodes.llm.jsonSchema.title'], { ns: 'workflow' })}
value={selectedSchemaViews}
onValueChange={(nextSchemaViews) => {
const value = nextSchemaViews[0]
if (value) handleSchemaViewChange(value)
}}
className="inline-flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"
>
{SCHEMA_VIEW_OPTIONS.map(({ Icon, text, value }) => (
<SegmentedControlItem key={value} value={value}>
<TabsTab
key={value}
value={value}
className="h-7 min-w-0 cursor-default justify-center gap-0.5 overflow-hidden rounded-lg border-[0.5px] border-b-[0.5px] border-transparent px-2 py-1 whitespace-nowrap text-text-secondary transition-colors duration-150 hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-0 focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid data-active:border-components-segmented-control-item-active-border data-active:bg-components-segmented-control-item-active-bg data-active:text-text-accent-light-mode-only data-active:shadow-xs data-active:shadow-shadow-shadow-3 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 [&&]:system-sm-medium"
>
<Icon className="size-4 shrink-0" />
<span className="p-0.5">{text}</span>
</SegmentedControlItem>
</TabsTab>
))}
</SegmentedControl>
</TabsList>
<div className="flex items-center gap-x-0.5">
{/* JSON Schema Generator */}
<JsonSchemaGenerator crossAxisOffset={btnWidth} onApply={handleApplySchema} />
@ -235,15 +239,15 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
<JsonImporter updateBtnWidth={updateBtnWidth} onSubmit={handleSubmit} />
</div>
</div>
<div className="flex grow flex-col gap-y-1 overflow-hidden px-6">
{selectedSchemaView === SchemaView.VisualEditor && (
<div className="flex grow flex-col overflow-hidden">
<TabsPanel value="visualEditor" className="flex grow flex-col gap-y-1 overflow-hidden px-6">
<VisualEditor schema={jsonSchema} onChange={handleVisualEditorUpdate} />
)}
{selectedSchemaView === SchemaView.JsonSchema && (
</TabsPanel>
<TabsPanel value="jsonSchema" className="flex grow flex-col gap-y-1 overflow-hidden px-6">
<SchemaEditor schema={json} onUpdate={handleSchemaEditorUpdate} />
)}
{parseError && <ErrorMessage message={parseError.message} />}
{validationError && <ErrorMessage message={validationError} />}
{parseError && <ErrorMessage message={parseError.message} />}
{validationError && <ErrorMessage message={validationError} />}
</TabsPanel>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
@ -264,7 +268,7 @@ function JsonSchemaConfigContent({ defaultSchema, onSave, onClose }: JsonSchemaC
</div>
</div>
</div>
</div>
</Tabs>
)
}

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DisplayContent } from '../display-content'
import { PreviewType } from '../types'
@ -24,15 +25,18 @@ describe('variable inspect display content', () => {
expect(handleTextChange).toHaveBeenCalledWith('updated markdown')
})
it('keeps the active view selected when clicking the selected segmented control item', () => {
it('keeps the active view selected when clicking the selected segmented control item', async () => {
const user = userEvent.setup()
render(<DisplayContent {...baseProps} />)
const codeButton = screen.getByRole('button', { name: 'workflow.nodes.templateTransform.code' })
const codeOption = screen.getByRole('radio', {
name: 'workflow.nodes.templateTransform.code',
})
expect(codeButton).toHaveAttribute('aria-pressed', 'true')
expect(codeOption).toHaveAttribute('aria-checked', 'true')
fireEvent.click(codeButton)
await user.click(codeOption)
expect(codeButton).toHaveAttribute('aria-pressed', 'true')
expect(codeOption).toHaveAttribute('aria-checked', 'true')
})
})

View File

@ -36,7 +36,7 @@ export function DisplayContent(props: DisplayContentProps) {
handleEditorChange,
className,
} = props
const [selectedViewModes, setSelectedViewModes] = useState<readonly ViewMode[]>([ViewMode.Code])
const [selectedViewMode, setSelectedViewMode] = useState<ViewMode>(ViewMode.Code)
const [isFocused, setIsFocused] = useState(false)
const { t } = useTranslation()
const viewOptions = [
@ -51,14 +51,6 @@ export function DisplayContent(props: DisplayContentProps) {
iconClassName: 'i-ri-eye-line',
},
]
const selectedViewMode = selectedViewModes[0] ?? ViewMode.Code
function handleViewModeChange(nextViewModes: ViewMode[]) {
const nextViewMode = nextViewModes[0]
if (nextViewMode) setSelectedViewModes([nextViewMode])
}
const chunkType = useMemo(() => {
if (previewType !== PreviewType.Chunks || !schemaType) return undefined
if (schemaType === 'general_structure') return ChunkingMode.text
@ -96,15 +88,15 @@ export function DisplayContent(props: DisplayContentProps) {
)}
<SegmentedControl<ViewMode>
aria-label={t(($) => $['common.preview'], { ns: 'workflow' })}
value={selectedViewModes}
onValueChange={handleViewModeChange}
value={selectedViewMode}
onValueChange={setSelectedViewMode}
className="shrink-0 rounded-md p-px"
>
{viewOptions.map(({ value, label, iconClassName }) => (
<SegmentedControlItem
key={value}
value={value}
className="h-5.5 gap-0.75 rounded-md p-px pr-0.5 pl-1.5 text-text-tertiary data-pressed:text-text-accent-light-mode-only"
className="h-5.5 gap-0.75 rounded-md p-px pr-0.5 pl-1.5 text-text-tertiary data-checked: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>

View File

@ -150,12 +150,12 @@ describe('AgentPreviewHeader', () => {
onModeChange,
})
const modeControl = screen.getByRole('group', {
const modeControl = screen.getByRole('radiogroup', {
name: 'agentV2.agentDetail.configure.rightPanel.modeLabel',
})
await user.click(
within(modeControl).getByRole('button', {
within(modeControl).getByRole('radio', {
name: 'agentV2.agentDetail.configure.rightPanel.preview',
}),
)

View File

@ -157,11 +157,8 @@ export function AgentPreviewHeader({
<div className="relative z-1 flex h-12 shrink-0 items-center justify-between gap-3 px-4 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<SegmentedControl<AgentConfigureRightPanelMode>
value={[mode]}
onValueChange={(value) => {
const nextMode = value[0]
if (nextMode && (nextMode !== 'preview' || previewEnabled)) onModeChange(nextMode)
}}
value={mode}
onValueChange={(value) => onModeChange(value)}
aria-label={t(($) => $['agentDetail.configure.rightPanel.modeLabel'])}
>
<SegmentedControlItem<AgentConfigureRightPanelMode> value="build" className="uppercase">

View File

@ -62,10 +62,10 @@ describe('RosterToolbar', () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderToolbar()
const publishedFilter = screen.getByRole('button', {
const publishedFilter = screen.getByRole('radio', {
name: /agentV2\.roster\.filters\.published/,
})
const draftsFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.drafts/ })
const draftsFilter = screen.getByRole('radio', { name: /agentV2\.roster\.filters\.drafts/ })
expect(publishedFilter).toBeEnabled()
expect(draftsFilter).toBeEnabled()
@ -82,11 +82,11 @@ describe('RosterToolbar', () => {
it('renders stable filter count badges and omits the all count', () => {
renderToolbar()
const allFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.all/ })
const publishedFilter = screen.getByRole('button', {
const allFilter = screen.getByRole('radio', { name: /agentV2\.roster\.filters\.all/ })
const publishedFilter = screen.getByRole('radio', {
name: /agentV2\.roster\.filters\.published/,
})
const draftsFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.drafts/ })
const draftsFilter = screen.getByRole('radio', { name: /agentV2\.roster\.filters\.drafts/ })
expect(allFilter).not.toHaveTextContent('3')
expect(within(publishedFilter).getByText('1')).toBeInTheDocument()

View File

@ -28,7 +28,7 @@ type RosterFilterItemProps = {
function RosterFilterItem({ count, label, value }: RosterFilterItemProps) {
return (
<SegmentedControlItem value={value} className="gap-1 data-pressed:text-text-secondary">
<SegmentedControlItem value={value} className="gap-1 data-checked:text-text-secondary">
<span>{label}</span>
{count !== undefined && (
<span className="flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary tabular-nums">
@ -47,12 +47,8 @@ function RosterStatusFilter({ draftAgents, publishedAgents }: RosterToolbarProps
<SegmentedControl
aria-label={t(($) => $['roster.filters.label'])}
className="shrink-0"
value={[filter]}
onValueChange={(value) => {
const nextFilter = value[0]
if (nextFilter) void setFilter(nextFilter)
}}
value={filter}
onValueChange={(value) => void setFilter(value)}
>
<RosterFilterItem value="all" label={t(($) => $['roster.filters.all'])} />
<RosterFilterItem

View File

@ -36,11 +36,8 @@ export function KnowledgeViewSwitcher({ value, onChange }: KnowledgeViewSwitcher
<SegmentedControl
className="max-w-full rounded-md p-px"
aria-label={t(($) => $['newKnowledge.viewLabel'])}
value={[value]}
onValueChange={(values) => {
const nextValue = values[0]
if (nextValue === 'legacy' || nextValue === 'new') onChange(nextValue)
}}
value={value}
onValueChange={(value) => onChange(value)}
>
<SegmentedControlItem
className="h-5.5 rounded-md px-1 py-px system-xs-medium"