From 36dfc6c2167f39ac4a792461a3529c770ee58c37 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:09:10 +0000 Subject: [PATCH] refactor(ui): enforce single-choice segmented controls (#40726) --- .../agent-v2/build-draft.steps.ts | 4 +- .../agent-v2/env-editor.steps.ts | 4 +- oxlint-suppressions.json | 11 -- packages/dify-ui/README.md | 4 +- .../src/pagination/__tests__/index.spec.tsx | 12 +- .../dify-ui/src/pagination/index.stories.tsx | 4 +- packages/dify-ui/src/pagination/index.tsx | 18 +-- .../__tests__/index.spec.tsx | 133 ++++++++++++------ .../src/segmented-control/index.stories.tsx | 29 ++-- .../dify-ui/src/segmented-control/index.tsx | 52 ++++--- .../tab-slider-new/__tests__/index.spec.tsx | 16 +-- .../components/base/tab-slider-new/index.tsx | 9 +- .../detail/completed/__tests__/index.spec.tsx | 6 +- .../datasets/list/__tests__/index.spec.tsx | 18 ++- web/app/components/tools/mcp/modal.tsx | 7 +- .../__tests__/view-type-select.spec.tsx | 24 ++-- .../block-selector/view-type-select.tsx | 11 +- .../__tests__/form-input-type-switch.spec.tsx | 31 ++++ .../components/form-input-type-switch.tsx | 112 +++++++-------- .../agent-orchestrate-panel-content.spec.tsx | 20 +-- .../components/__tests__/timeout.spec.tsx | 12 +- .../nodes/human-input/components/timeout.tsx | 56 +++----- .../nodes/llm/__tests__/panel.spec.tsx | 10 +- .../__tests__/json-schema-config.spec.tsx | 90 ++++++++++++ .../json-schema-config.tsx | 96 +++++++------ .../__tests__/display-content.spec.tsx | 14 +- .../variable-inspect/display-content.tsx | 16 +-- .../preview/__tests__/header.spec.tsx | 4 +- .../configure/components/preview/header.tsx | 7 +- .../__tests__/roster-toolbar.spec.tsx | 10 +- .../roster/components/roster-toolbar.tsx | 10 +- .../components/knowledge-view-switcher.tsx | 7 +- 32 files changed, 479 insertions(+), 378 deletions(-) create mode 100644 web/app/components/workflow/nodes/_base/components/__tests__/form-input-type-switch.spec.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-schema-config.spec.tsx diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index 4e07595de4f..de28838dc27 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -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.') diff --git a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts index 9eec4dfdba8..45983d796d2 100644 --- a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts +++ b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts @@ -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() }, ) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 13f8059593c..ee5ab9e82a1 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 5160053904a..a42201e6ed6 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -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 diff --git a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx index 85bf98e97c2..a73ada5070e 100644 --- a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx @@ -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 () => { diff --git a/packages/dify-ui/src/pagination/index.stories.tsx b/packages/dify-ui/src/pagination/index.stories.tsx index 02df3d87bcf..b1332d40f09 100644 --- a/packages/dify-ui/src/pagination/index.stories.tsx +++ b/packages/dify-ui/src/pagination/index.stories.tsx @@ -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') }, } diff --git a/packages/dify-ui/src/pagination/index.tsx b/packages/dify-ui/src/pagination/index.tsx index 3daa203a71f..fa9f31391b7 100644 --- a/packages/dify-ui/src/pagination/index.tsx +++ b/packages/dify-ui/src/pagination/index.tsx @@ -510,23 +510,15 @@ function PaginationPageSize({ {label} { - 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) => ( - 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} diff --git a/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx index a45c1eb9a19..e6ecee06ac3 100644 --- a/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/segmented-control/__tests__/index.spec.tsx @@ -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 ( + <> + value={10} onValueChange={() => {}} aria-label="Page size"> + value={10}>10 + value={20}>20 + + {/* @ts-expect-error segmented controls require either value or defaultValue */} + + One + + + ) +} + +void SegmentedControlTypeExamples + +describe('SegmentedControl', () => { + it('exposes a required single choice through radio semantics', async () => { const screen = await render( - + One Two , ) + 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( - - One - Two - , - ) - - 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( - + One Two , ) - 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( - + One Two , ) - 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( - - - One + + One + + Two - + Three + , + ) + + 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( +
+ + One + Two + +
, + ) + + 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( + + One + Two , ) - 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') }) }) diff --git a/packages/dify-ui/src/segmented-control/index.stories.tsx b/packages/dify-ui/src/segmented-control/index.stories.tsx index 7377bf10314..b8bbc174eed 100644 --- a/packages/dify-ui/src/segmented-control/index.stories.tsx +++ b/packages/dify-ui/src/segmented-control/index.stories.tsx @@ -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 export default meta @@ -44,7 +47,7 @@ function SegmentedControlExample({ }: SegmentedControlProps) { return ( @@ -113,7 +116,7 @@ export const DesignSpec: Story = { export const DataAttributeStates: Story = { render: () => (
- + @@ -125,39 +128,27 @@ export const DataAttributeStates: Story = { - + - + - - - - - - - - - - - -
), 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.', }, }, }, diff --git a/packages/dify-ui/src/segmented-control/index.tsx b/packages/dify-ui/src/segmented-control/index.tsx index 342691bdf58..b9dec5e8682 100644 --- a/packages/dify-ui/src/segmented-control/index.tsx +++ b/packages/dify-ui/src/segmented-control/index.tsx @@ -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 = Omit< - BaseToggleGroupNS.Props, - 'className' -> & { - className?: string -} +type SegmentedControlSelectionProps = + | { + value: Value + defaultValue?: never + } + | { + value?: never + defaultValue: Value + } -function SegmentedControl({ - className, - ...props -}: SegmentedControlProps) { +type SegmentedControlProps = Omit< + BaseRadioGroupNS.Props, + 'className' | 'defaultValue' | 'value' +> & + SegmentedControlSelectionProps & { + className?: string + } + +function SegmentedControl({ className, ...props }: SegmentedControlProps) { return ( - 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({ ) } -type SegmentedControlItemProps = Omit< - BaseToggleNS.Props, +type SegmentedControlItemProps = Omit< + BaseRadioNS.Root.Props, 'className' > & { className?: string } -function SegmentedControlItem({ +function SegmentedControlItem({ className, + nativeButton = true, + render = - ) : ( - - onChange(VarType.variable)} - > - - - } - /> - {variableLabel} - - )} - {value === VarType.constant ? ( - - ) : ( - - onChange(VarType.constant)} - > - - - } - /> - {inputLabel} - - )} - + + value={value} + onValueChange={(value) => onChange(value)} + disabled={readonly} + aria-label={`${variableLabel}, ${inputLabel}`} + className="h-8 shrink-0" + > + + + + + + +
) } + export default FormInputTypeSwitch diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx index 6f213095996..8821db7e549 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx @@ -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() diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx index 13be4ebff39..e827bf3548c 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/timeout.spec.tsx @@ -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() 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() fireEvent.change(screen.getByTestId('timeout-input'), { target: { value: 'abc' } }) @@ -52,8 +55,9 @@ describe('TimeoutInput', () => { rerender() - 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() }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/timeout.tsx b/web/app/components/workflow/nodes/human-input/components/timeout.tsx index 758b28ad6a0..3af8bcb7845 100644 --- a/web/app/components/workflow/nodes/human-input/components/timeout.tsx +++ b/web/app/components/workflow/nodes/human-input/components/timeout.tsx @@ -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 = ({ 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) => { const value = e.target.value @@ -31,40 +37,20 @@ const TimeoutInput: FC = ({ timeout, unit, onChange, readonly }) => { onChange={handleValueChange} disabled={readonly} /> -
-
!readonly && onChange({ timeout, unit: 'day' })} - > -
- {t(($) => $[`${i18nPrefix}.timeout.days`], { ns: 'workflow' })} -
-
-
!readonly && onChange({ timeout, unit: 'hour' })} - > -
- {t(($) => $[`${i18nPrefix}.timeout.hours`], { ns: 'workflow' })} -
-
-
+ + value={unit} + onValueChange={(unit) => onChange({ timeout, unit })} + disabled={readonly} + aria-label={timeoutLabel} + className="gap-0.5" + > + +
{daysLabel}
+
+ +
{hoursLabel}
+
+
) } diff --git a/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx index 89b9573603f..fb7943b65a8 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/llm/__tests__/panel.spec.tsx @@ -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(() => { diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-schema-config.spec.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-schema-config.spec.tsx new file mode 100644 index 00000000000..bf27746297b --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/__tests__/json-schema-config.spec.tsx @@ -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: () =>
Visual editor panel
, +})) + +vi.mock('../schema-editor', () => ({ + default: ({ schema, onUpdate }: { schema: string; onUpdate: (schema: string) => void }) => ( +