mirror of
https://github.com/langgenius/dify.git
synced 2026-08-15 04:59:46 +08:00
refactor(ui): enforce single-choice segmented controls (#40726)
This commit is contained in:
parent
7a6f380f2c
commit
36dfc6c216
@ -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.')
|
||||
|
||||
@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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')
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user