chore: improve accessibility of basic app orchestration pages (#42428)

This commit is contained in:
Joel authored and GitHub committed 2026-09-17 12:11:14 +00:00
1 parent d21518f8fe
commit 35e4e4f6bd
38 files changed
+679 -301

No files matched your search

-18
View File
@@ -324,11 +324,6 @@
"count": 1
}
},
"web/app/components/app/configuration/config-vision/__tests__/index.spec.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/app/configuration/config/__tests__/agent-setting-button.spec.tsx": {
"typescript/no-explicit-any": {
"count": 2
@@ -378,14 +373,6 @@
"count": 4
}
},
"web/app/components/app/configuration/config/automatic/idea-output.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/configuration/config/automatic/instruction-editor.tsx": {
"typescript/no-explicit-any": {
"count": 2
@@ -1027,11 +1014,6 @@
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/feature-card.tsx": {
"typescript/no-explicit-any": {
"count": 5
}
},
"web/app/components/base/features/new-feature-panel/file-upload/__tests__/index.spec.tsx": {
"import/no-duplicates": {
"count": 1
@@ -327,6 +327,14 @@ describe('ConfigurationView', () => {
expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument()
})
it('provides the page heading inside the parent-owned main landmark', () => {
render(<ConfigurationView {...createViewModel()} />)
expect(
screen.getByRole('heading', { level: 1, name: 'appDebug.orchestrate' }),
).toBeInTheDocument()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
})
it('should open the mobile debug panel from the header button', () => {
const onOpenDebugPanel = vi.fn()
render(<ConfigurationView {...createViewModel({ isMobile: true, onOpenDebugPanel })} />)
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import Prompt from '../simple-prompt-input'
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({ features: { opening: { enabled: false } }, setFeatures: vi.fn() }),
}),
}))
describe('SimplePromptInput accessibility', () => {
it.each([AppModeEnum.CHAT, AppModeEnum.COMPLETION])(
'names the real editor from the visible section heading in %s mode',
(mode) => {
render(<Prompt mode={mode} promptTemplate="" promptVariables={[]} noResize />)
const heading = screen.getByRole('heading', { level: 2 })
expect(screen.getByRole('textbox', { name: heading.textContent! })).toHaveAttribute(
'aria-labelledby',
heading.id,
)
},
)
it('keeps an accessible editor name when embedded without a section heading', () => {
render(
<Prompt mode={AppModeEnum.CHAT} promptTemplate="" promptVariables={[]} noTitle noResize />,
)
expect(screen.getByRole('textbox', { name: 'appDebug.chatSubTitle' })).toBeInTheDocument()
expect(screen.queryByRole('heading')).not.toBeInTheDocument()
})
})
@@ -18,7 +18,7 @@ const HistoryPanel: FC<Props> = ({ showWarning, onShowEditModal }) => {
className="mt-2"
title={
<div className="flex items-center gap-2">
<div>{t(($) => $['feature.conversationHistory.title'], { ns: 'appDebug' })}</div>
<h2>{t(($) => $['feature.conversationHistory.title'], { ns: 'appDebug' })}</h2>
</div>
}
headerIcon={
@@ -8,7 +8,7 @@ import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
import { useState } from 'react'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/config-var'
@@ -52,6 +52,11 @@ const Prompt: FC<ISimplePromptInput> = ({
noResize,
}) => {
const { t } = useTranslation()
const titleId = useId()
const title =
mode !== AppModeEnum.COMPLETION
? t(($) => $.chatSubTitle, { ns: 'appDebug' })
: t(($) => $.completionSubTitle, { ns: 'appDebug' })
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const featuresStore = useFeaturesStore()
@@ -197,11 +202,9 @@ const Prompt: FC<ISimplePromptInput> = ({
{!noTitle && (
<div className="flex h-11 items-center justify-between pr-2.5 pl-3">
<div className="flex items-center space-x-1">
<div className="system-sm-semibold-uppercase text-text-secondary">
{mode !== AppModeEnum.COMPLETION
? t(($) => $.chatSubTitle, { ns: 'appDebug' })
: t(($) => $.completionSubTitle, { ns: 'appDebug' })}
</div>
<h2 id={titleId} className="system-sm-semibold-uppercase text-text-secondary">
{title}
</h2>
{!readonly && (
<Infotip
aria-label={t(($) => $.promptTip, { ns: 'appDebug' })}
@@ -233,6 +236,8 @@ const Prompt: FC<ISimplePromptInput> = ({
}
>
<PromptEditor
aria-labelledby={noTitle ? undefined : titleId}
aria-label={noTitle ? title : undefined}
className="min-h-52.5"
compact
value={promptTemplate}
@@ -0,0 +1,60 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import VarItem from '../var-item'
// Real CSS and native Tab navigation catch actions hidden behind group-hover;
// happy-dom cannot establish whether those actions are actually visible and focusable.
it('keeps variable actions visible and keyboard reachable without hovering and skips readonly actions', async () => {
const onEdit = vi.fn()
const onRemove = vi.fn()
const screen = await render(
<div className="w-96 p-4">
<button type="button" className="mb-8">
Before variables
</button>
<VarItem
name="region"
label="Region"
required={false}
type="string"
onEdit={onEdit}
onRemove={onRemove}
/>
<section aria-label="Readonly variable">
<VarItem
readonly
name="system_region"
label="System region"
required={false}
type="string"
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
</section>
<button type="button">After variables</button>
</div>,
)
// Leave the pointer on this separate control throughout keyboard navigation.
await screen.getByRole('button', { name: 'Before variables' }).click()
const edit = screen.getByRole('button', { name: 'common.operation.edit' })
const remove = screen.getByRole('button', { name: 'common.operation.delete' })
await expect.element(edit).toBeVisible()
await expect.element(remove).toBeVisible()
await userEvent.tab()
await expect.element(edit).toHaveFocus()
expect(edit.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard('{Enter}')
expect(onEdit).toHaveBeenCalledTimes(1)
await userEvent.tab()
await expect.element(remove).toHaveFocus()
expect(remove.element().checkVisibility({ checkOpacity: true })).toBe(true)
await userEvent.keyboard(' ')
expect(onRemove).toHaveBeenCalledTimes(1)
await expect
.element(screen.getByRole('region', { name: 'Readonly variable' }).getByRole('button'))
.not.toBeInTheDocument()
await userEvent.tab()
await expect.element(screen.getByRole('button', { name: 'After variables' })).toHaveFocus()
})
@@ -1,11 +1,13 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import VarItem from '../var-item'
describe('VarItem', () => {
it('should render variable metadata and allow editing', () => {
it('should reach and activate editing with the keyboard without hovering', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onRemove = vi.fn()
const { container } = render(
render(
<VarItem
canDrag
name="api_key"
@@ -20,13 +22,17 @@ describe('VarItem', () => {
expect(screen.getByTitle('api_key · API Key')).toBeInTheDocument()
expect(screen.getByText('required')).toBeInTheDocument()
const editButton = container.querySelector('.mr-1.flex.size-6') as HTMLElement
fireEvent.click(editButton)
await user.tab()
expect(screen.getByRole('button', { name: 'api_key' })).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'common.operation.edit' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(onEdit).toHaveBeenCalledTimes(1)
})
it('should call remove when clicking the delete action', () => {
it('should reach and activate deletion with the keyboard without hovering', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
render(
<VarItem
@@ -39,8 +45,28 @@ describe('VarItem', () => {
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' }))
await user.tab()
await user.tab()
expect(screen.getByRole('button', { name: 'common.operation.delete' })).toHaveFocus()
await user.keyboard(' ')
expect(onRemove).toHaveBeenCalledTimes(1)
})
it('does not offer mutation actions for a readonly variable', () => {
render(
<VarItem
readonly
name="region"
label="Region"
required={false}
type="string"
onEdit={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.queryByRole('button', { name: 'common.operation.edit' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.operation.delete' }),
).not.toBeInTheDocument()
})
})
@@ -313,7 +313,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
className="mt-2"
title={
<div className="flex items-center">
<div className="mr-1">{t(($) => $.variableTitle, { ns: 'appDebug' })}</div>
<h2 className="mr-1">{t(($) => $.variableTitle, { ns: 'appDebug' })}</h2>
{!readonly && (
<Infotip
aria-label={t(($) => $.variableTip, { ns: 'appDebug' })}
@@ -71,37 +71,34 @@ const VarItem: FC<ItemProps> = ({
<span className="system-xs-medium text-text-tertiary">{label}</span>
</div>
</div>
<div className="shrink-0">
<div className={cn('flex items-center', !readonly && 'group-hover:hidden')}>
<div className="flex shrink-0 items-center">
<div className="flex items-center">
{required && <Badge text="required" />}
<span className="pr-1 pl-2 system-xs-regular text-text-tertiary">{type}</span>
<IconTypeIcon type={type as IInputTypeIconProps['type']} className="text-text-tertiary" />
</div>
<div
className={cn(
'hidden items-center justify-end rounded-lg',
!readonly && 'group-hover:flex',
)}
>
<button
type="button"
aria-label={t(($) => $['operation.edit'], { ns: 'common' })}
className="mr-1 flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onEdit}
>
<RiEditLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
<button
type="button"
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
className="flex size-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-text-tertiary hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden"
onClick={onRemove}
onMouseOver={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className="size-4" aria-hidden="true" />
</button>
</div>
{!readonly && (
<div className="flex items-center justify-end rounded-lg">
<button
type="button"
aria-label={t(($) => $['operation.edit'], { ns: 'common' })}
className="mr-1 flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onEdit}
>
<RiEditLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
<button
type="button"
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
className="flex size-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-text-tertiary hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden"
onClick={onRemove}
onMouseOver={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className="size-4" aria-hidden="true" />
</button>
</div>
)}
</div>
</div>
)
@@ -22,7 +22,7 @@ vi.mock('use-context-selector', async (importOriginal) => {
const mockUseFeatures = vi.fn()
const mockUseFeaturesStore = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector),
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
@@ -99,8 +99,13 @@ describe('ConfigVision', () => {
it('should show the toggle and parameter controls when visible', () => {
render(<ConfigVision />)
expect(screen.getByText('appDebug.vision.name'))!.toBeInTheDocument()
expect(screen.getByRole('switch'))!.toHaveAttribute('aria-checked', 'false')
expect(
screen.getByRole('heading', { level: 2, name: 'appDebug.vision.name' }),
).toBeInTheDocument()
expect(screen.getByRole('switch', { name: 'appDebug.vision.name' }))!.toHaveAttribute(
'aria-checked',
'false',
)
})
it('should enable both image and video uploads when toggled on with video support', async () => {
@@ -114,7 +119,7 @@ describe('ConfigVision', () => {
})
render(<ConfigVision />)
await user.click(screen.getByRole('switch'))
await user.click(screen.getByRole('switch', { name: 'appDebug.vision.name' }))
const updatedFile = getLatestFileConfig()
expect(updatedFile.allowed_file_types).toEqual([
@@ -140,7 +145,7 @@ describe('ConfigVision', () => {
})
render(<ConfigVision />)
await user.click(screen.getByRole('switch'))
await user.click(screen.getByRole('switch', { name: 'appDebug.vision.name' }))
const updatedFile = getLatestFileConfig()
expect(updatedFile.allowed_file_types).toEqual([])
@@ -161,7 +166,7 @@ describe('ConfigVision', () => {
})
render(<ConfigVision />)
await user.click(screen.getByRole('switch'))
await user.click(screen.getByRole('switch', { name: 'appDebug.vision.name' }))
const updatedFile = getLatestFileConfig()
expect(updatedFile.allowed_file_types).toEqual([SupportUploadFileTypes.document])
@@ -1,58 +1,66 @@
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileUpload } from '@/app/components/base/features/types'
import { render, screen } from '@testing-library/react'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FeaturesProvider } from '@/app/components/base/features/context'
import { Resolution, TransferMethod } from '@/types/app'
import ParamConfig from '../param-config'
const mockUseFeatures = vi.fn()
const mockUseFeaturesStore = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => {
const file: FileUpload = {
enabled: true,
allowed_file_types: [],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
...fileOverrides,
}
const featureStoreState = {
features: { file },
setFeatures: vi.fn(),
showFeaturesModal: false,
setShowFeaturesModal: vi.fn(),
} as unknown as FeatureStoreState
mockUseFeatures.mockImplementation((selector) => selector(featureStoreState))
mockUseFeaturesStore.mockReturnValue({
getState: () => featureStoreState,
})
}
const renderConfig = () =>
render(
<FeaturesProvider
features={{
file: {
enabled: true,
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
image: { enabled: true, detail: Resolution.low },
},
}}
>
<ParamConfig />
</FeaturesProvider>,
)
describe('ParamConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
setupFeatureStore()
})
it('should toggle the settings panel when clicking the trigger', async () => {
it('opens a named settings panel and exposes named, keyboard-operable radio groups', async () => {
const user = userEvent.setup()
render(<ParamConfig />)
expect(screen.queryByText('appDebug.vision.visionSettings.title')).not.toBeInTheDocument()
renderConfig()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'appDebug.voice.settings' }))
expect(await screen.findByText('appDebug.vision.visionSettings.title')).toBeInTheDocument()
const panel = await screen.findByRole('dialog', {
name: 'appDebug.vision.visionSettings.title',
})
const resolution = within(panel).getByRole('radiogroup', {
name: 'appDebug.vision.visionSettings.resolution',
})
const low = within(resolution).getByRole('radio', {
name: 'appDebug.vision.visionSettings.low',
})
expect(low).toBeChecked()
await user.click(low)
await user.keyboard('{ArrowLeft}')
expect(
within(resolution).getByRole('radio', { name: 'appDebug.vision.visionSettings.high' }),
).toBeChecked()
const uploadMethods = within(panel).getByRole('radiogroup', {
name: 'appDebug.vision.visionSettings.uploadMethod',
})
const both = within(uploadMethods).getByRole('radio', {
name: 'appDebug.vision.visionSettings.both',
})
expect(both).toBeChecked()
await user.click(both)
await user.keyboard('{ArrowRight}')
expect(
within(uploadMethods).getByRole('radio', {
name: 'appDebug.vision.visionSettings.localUpload',
}),
).toBeChecked()
await user.keyboard('{ArrowRight}')
expect(
within(uploadMethods).getByRole('radio', { name: 'appDebug.vision.visionSettings.url' }),
).toBeChecked()
})
})
@@ -5,7 +5,7 @@ import { Switch } from '@langgenius/dify-ui/switch'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback } from 'react'
import { useCallback, useId } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
// import { Resolution } from '@/types/app'
@@ -21,6 +21,7 @@ import ParamConfig from './param-config'
const ConfigVision: FC = () => {
const { t } = useTranslation()
const titleId = useId()
const { isShowVisionConfig, isAllowVideoUpload, readonly } = useContext(ConfigContext)
const file = useFeatures((s) => s.features.file)
const featuresStore = useFeaturesStore()
@@ -71,9 +72,9 @@ const ConfigVision: FC = () => {
</div>
</div>
<div className="flex grow items-center">
<div className="mr-1 system-sm-semibold text-text-secondary">
<h2 id={titleId} className="mr-1 system-sm-semibold text-text-secondary">
{t(($) => $['vision.name'], { ns: 'appDebug' })}
</div>
</h2>
<Infotip
aria-label={t(($) => $['vision.description'], { ns: 'appDebug' })}
popupClassName="w-[180px]"
@@ -128,7 +129,12 @@ const ConfigVision: FC = () => {
<>
<ParamConfig />
<div className="mr-3 ml-1 h-3.5 w-px bg-divider-regular"></div>
<Switch checked={isImageEnabled} onCheckedChange={handleChange} size="md" />
<Switch
aria-labelledby={titleId}
checked={isImageEnabled}
onCheckedChange={handleChange}
size="md"
/>
</>
)}
</div>
@@ -1,6 +1,9 @@
'use client'
import type { FC } from 'react'
import type { FileUpload } from '@/app/components/base/features/types'
import { Field, FieldItem } from '@langgenius/dify-ui/field'
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio-group'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback } from 'react'
@@ -8,11 +11,12 @@ import { useTranslation } from 'react-i18next'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { Infotip } from '@/app/components/base/infotip'
import ParamItem from '@/app/components/base/param-item'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { Resolution, TransferMethod } from '@/types/app'
const MIN = 1
const MAX = 6
const optionClassName =
'flex h-8 w-full cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary data-unchecked:cursor-pointer data-unchecked:hover:border-components-option-card-option-border-hover data-unchecked:hover:bg-components-option-card-option-bg-hover data-unchecked:hover:shadow-xs focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-checked:border-[1.5px] data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:system-sm-medium data-checked:shadow-xs'
const ParamConfigContent: FC = () => {
const { t } = useTranslation()
const file = useFeatures((s) => s.features.file)
@@ -40,17 +44,32 @@ const ParamConfigContent: FC = () => {
[featuresStore],
)
const uploadMethod =
file?.allowed_file_upload_methods?.length === 1
? (file.allowed_file_upload_methods[0] ?? null)
: file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) &&
file.allowed_file_upload_methods.includes(TransferMethod.remote_url)
? 'both'
: null
return (
<div>
<div className="text-base/6 font-semibold text-text-primary">
{t(($) => $['vision.visionSettings.title'], { ns: 'appDebug' })}
</div>
<div className="space-y-6 pt-3">
<div>
<div className="space-y-6 pt-3">
<Field>
<Fieldset
render={
<RadioGroup<Resolution | null>
value={file?.image?.detail ?? null}
onValueChange={(detail) => {
if (detail) handleChange({ ...file, image: { detail } })
}}
className="block"
/>
}
>
<div className="mb-2 flex items-center space-x-1">
<div className="text-[13px] leading-4.5 font-semibold text-text-secondary">
<FieldsetLegend className="m-0 py-0 text-[13px] leading-4.5 font-semibold text-text-secondary">
{t(($) => $['vision.visionSettings.resolution'], { ns: 'appDebug' })}
</div>
</FieldsetLegend>
<Infotip
aria-label={t(($) => $['vision.visionSettings.resolutionTooltip'], {
ns: 'appDebug',
@@ -65,106 +84,104 @@ const ParamConfigContent: FC = () => {
</Infotip>
</div>
<div className="flex items-center gap-1">
<OptionCard
className="grow"
title={t(($) => $['vision.visionSettings.high'], { ns: 'appDebug' })}
selected={file?.image?.detail === Resolution.high}
onSelect={() =>
handleChange({
...file,
image: { detail: Resolution.high },
})
}
/>
<OptionCard
className="grow"
title={t(($) => $['vision.visionSettings.low'], { ns: 'appDebug' })}
selected={file?.image?.detail === Resolution.low}
onSelect={() =>
handleChange({
...file,
image: { detail: Resolution.low },
})
}
/>
{[
{
value: Resolution.high,
label: t(($) => $['vision.visionSettings.high'], { ns: 'appDebug' }),
},
{
value: Resolution.low,
label: t(($) => $['vision.visionSettings.low'], { ns: 'appDebug' }),
},
].map((option) => (
<FieldItem key={option.value} className="grow">
<RadioItem<Resolution>
value={option.value}
nativeButton
render={<button type="button" />}
className={optionClassName}
>
{option.label}
</RadioItem>
</FieldItem>
))}
</div>
</div>
<div>
<div className="mb-2 text-[13px] leading-4.5 font-semibold text-text-secondary">
</Fieldset>
</Field>
<Field>
<Fieldset
render={
<RadioGroup<TransferMethod | 'both' | null>
value={uploadMethod}
onValueChange={(method) => {
if (!method) return
handleChange({
...file,
allowed_file_upload_methods:
method === 'both'
? [TransferMethod.local_file, TransferMethod.remote_url]
: [method],
})
}}
className="block"
/>
}
>
<FieldsetLegend className="mb-2 py-0 text-[13px] leading-4.5 font-semibold text-text-secondary">
{t(($) => $['vision.visionSettings.uploadMethod'], { ns: 'appDebug' })}
</div>
</FieldsetLegend>
<div className="flex items-center gap-1">
<OptionCard
className="grow"
title={t(($) => $['vision.visionSettings.both'], { ns: 'appDebug' })}
selected={
!!file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) &&
!!file?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)
}
onSelect={() =>
handleChange({
...file,
allowed_file_upload_methods: [
TransferMethod.local_file,
TransferMethod.remote_url,
],
})
}
/>
<OptionCard
className="grow"
title={t(($) => $['vision.visionSettings.localUpload'], { ns: 'appDebug' })}
selected={
!!file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) &&
file?.allowed_file_upload_methods?.length === 1
}
onSelect={() =>
handleChange({
...file,
allowed_file_upload_methods: [TransferMethod.local_file],
})
}
/>
<OptionCard
className="grow"
title={t(($) => $['vision.visionSettings.url'], { ns: 'appDebug' })}
selected={
!!file?.allowed_file_upload_methods?.includes(TransferMethod.remote_url) &&
file?.allowed_file_upload_methods?.length === 1
}
onSelect={() =>
handleChange({
...file,
allowed_file_upload_methods: [TransferMethod.remote_url],
})
}
/>
{[
{
value: 'both' as const,
label: t(($) => $['vision.visionSettings.both'], { ns: 'appDebug' }),
},
{
value: TransferMethod.local_file,
label: t(($) => $['vision.visionSettings.localUpload'], { ns: 'appDebug' }),
},
{
value: TransferMethod.remote_url,
label: t(($) => $['vision.visionSettings.url'], { ns: 'appDebug' }),
},
].map((option) => (
<FieldItem key={option.value} className="grow">
<RadioItem<TransferMethod | 'both'>
value={option.value}
nativeButton
render={<button type="button" />}
className={optionClassName}
>
{option.label}
</RadioItem>
</FieldItem>
))}
</div>
</div>
<div>
<ParamItem
id="upload_limit"
className=""
name={t(($) => $['vision.visionSettings.uploadLimit'], { ns: 'appDebug' })}
noTooltip
{...{
default: 2,
step: 1,
min: MIN,
max: MAX,
}}
value={file?.number_limits || 3}
enable={true}
onChange={(_key: string, value: number) => {
if (!value) return
</Fieldset>
</Field>
<div>
<ParamItem
id="upload_limit"
className=""
name={t(($) => $['vision.visionSettings.uploadLimit'], { ns: 'appDebug' })}
noTooltip
{...{
default: 2,
step: 1,
min: MIN,
max: MAX,
}}
value={file?.number_limits || 3}
enable={true}
onChange={(_key: string, value: number) => {
if (!value) return
handleChange({
...file,
number_limits: value,
})
}}
/>
</div>
handleChange({
...file,
number_limits: value,
})
}}
/>
</div>
</div>
)
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiSettings2Line } from '@remixicon/react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -26,7 +26,10 @@ const ParamsConfig: FC = () => {
sideOffset={4}
className="border-none bg-transparent shadow-none"
>
<div className="w-80 space-y-3 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg sm:w-103">
<div className="w-80 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg sm:w-103">
<PopoverTitle className="text-base/6 font-semibold text-text-primary">
{t(($) => $['vision.visionSettings.title'], { ns: 'appDebug' })}
</PopoverTitle>
<ParamConfigContent />
</div>
</PopoverContent>
@@ -159,7 +159,7 @@ const AgentTools: FC = () => {
noBodySpacing={tools.length === 0}
title={
<div className="flex items-center">
<div className="mr-1">{t(($) => $['agent.tools.name'], { ns: 'appDebug' })}</div>
<h2 className="mr-1">{t(($) => $['agent.tools.name'], { ns: 'appDebug' })}</h2>
<Infotip aria-label={t(($) => $['agent.tools.description'], { ns: 'appDebug' })}>
{t(($) => $['agent.tools.description'], { ns: 'appDebug' })}
</Infotip>
@@ -0,0 +1,117 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { AppModeEnum } from '@/types/app'
import GetAutomaticRes from '../get-automatic-res'
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
const { default: appDebug } = await import('@/i18n/en-US/app-debug.json')
const { default: common } = await import('@/i18n/en-US/common.json')
return createReactI18nextMock({ ...appDebug, ...common })
})
vi.mock(
'@/app/components/header/account-setting/model-provider-page/hooks',
async (importOriginal) => ({
...(await importOriginal<
typeof import('@/app/components/header/account-setting/model-provider-page/hooks')
>()),
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
defaultModel: { model: 'gpt-4.1-mini', provider: { provider: 'openai' } },
}),
}),
)
// The independent model picker is outside this regression; the real generator,
// Lexical editors, result actions, and overlay remain the layout owners under test.
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <button type="button">gpt-4.1-mini</button>,
}),
)
vi.mock('@/service/debug', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/debug')>()),
generateBasicAppFirstTimeRule: async () => ({
prompt: 'Summarize the supplied meeting notes into actionable next steps for the team.',
variables: [],
opening_statement: '',
}),
generateRule: vi.fn(),
}))
const expectWithin = (element: Element, container: Element) => {
const rect = element.getBoundingClientRect()
const bounds = container.getBoundingClientRect()
expect(rect.width).toBeGreaterThan(0)
expect(rect.left).toBeGreaterThanOrEqual(bounds.left - 1)
expect(rect.right).toBeLessThanOrEqual(bounds.right + 1)
}
describe('Prompt generator reflow', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
afterEach(async () => {
await page.viewport(1280, 720)
})
it.each([320, 720, 1440])(
'keeps inputs and generated result actions within a %i CSS pixel viewport',
async (width) => {
// Unit tests cannot detect the original fixed 1140px popup being clipped.
// Rendering the production dialog and Result proves both panels remain usable.
await page.viewport(width, 900)
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const screen = await render(
<QueryClientProvider client={queryClient}>
<GetAutomaticRes
mode={AppModeEnum.CHAT}
isShow
onClose={vi.fn()}
onFinished={vi.fn()}
flowId={`reflow-${width}`}
isBasicMode
/>
</QueryClientProvider>,
)
const dialog = screen.getByRole('dialog', { name: 'Prompt Generator' })
const instructions = screen.getByRole('textbox', { name: 'Instructions' })
const generate = screen.getByRole('button', { name: 'Generate', exact: true })
await expect.element(dialog).toBeVisible()
await instructions.fill('Summarize meeting notes for the team.')
await generate.click()
const apply = screen.getByRole('button', { name: 'Apply', exact: true })
const copy = screen.getByRole('button', { name: 'Copy', exact: true })
const result = screen.getByText(
'Summarize the supplied meeting notes into actionable next steps for the team.',
{ exact: true },
)
await expect.element(apply).toBeVisible()
await expect.element(result).toBeVisible()
const dialogElement = dialog.element()
const bounds = dialogElement.getBoundingClientRect()
expect(bounds.left).toBeGreaterThanOrEqual(0)
expect(bounds.right).toBeLessThanOrEqual(width)
expect(dialogElement.scrollWidth).toBeLessThanOrEqual(dialogElement.clientWidth + 1)
for (const element of [instructions, generate, apply, copy, result])
expectWithin(element.element(), dialogElement)
const instructionsRect = instructions.element().getBoundingClientRect()
const resultRect = result.element().getBoundingClientRect()
if (width < 1280) expect(resultRect.top).toBeGreaterThan(instructionsRect.bottom)
else expect(resultRect.left).toBeGreaterThan(instructionsRect.right)
await apply.click()
await expect.element(screen.getByRole('alertdialog')).toBeVisible()
queryClient.clear()
},
)
})
@@ -141,6 +141,8 @@ describe('GetAutomaticRes', () => {
/>,
)
expect(screen.getByRole('dialog', { name: /generate\.title/ })).toBeInTheDocument()
fireEvent.click(screen.getByText(/(?:^|\.)generate\.template\.pythonDebugger\.name(?=$|:)/))
await waitFor(() => {
@@ -1,26 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import IdeaOutput from '../idea-output'
describe('IdeaOutput', () => {
it('should toggle the fold state and propagate textarea changes when expanded', () => {
const onChange = vi.fn()
render(<IdeaOutput value="Initial idea" onChange={onChange} />)
it('lets keyboard users expand, edit, and collapse the optional output', async () => {
const user = userEvent.setup()
function Editor() {
const [value, setValue] = useState('Initial idea')
return <IdeaOutput value={value} onChange={setValue} />
}
render(<Editor />)
expect(
screen.queryByPlaceholderText(/(?:^|\.)generate\.idealOutputPlaceholder(?=$|:)/),
).not.toBeInTheDocument()
const trigger = screen.getByRole('button', { name: /generate\.idealOutput/ })
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
fireEvent.click(screen.getByText(/(?:^|\.)generate\.idealOutput(?=$|:)/))
await user.tab()
expect(trigger).toHaveFocus()
await user.keyboard('{Enter}')
expect(trigger).toHaveAttribute('aria-expanded', 'true')
const textarea = screen.getByPlaceholderText(/(?:^|\.)generate\.idealOutputPlaceholder(?=$|:)/)
fireEvent.change(textarea, { target: { value: 'Updated idea' } })
const textarea = screen.getByRole('textbox', { name: /generate\.idealOutput/ })
await user.tab()
expect(textarea).toHaveFocus()
await user.clear(textarea)
await user.type(textarea, 'Updated idea')
expect(textarea).toHaveValue('Updated idea')
expect(onChange).toHaveBeenCalledWith('Updated idea')
await user.tab({ shift: true })
await user.keyboard(' ')
expect(trigger).toHaveAttribute('aria-expanded', 'false')
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument())
expect(trigger).toHaveFocus()
fireEvent.click(screen.getByText(/(?:^|\.)generate\.idealOutput(?=$|:)/))
expect(
screen.queryByPlaceholderText(/(?:^|\.)generate\.idealOutputPlaceholder(?=$|:)/),
).not.toBeInTheDocument()
await user.keyboard('{Enter}')
expect(screen.getByRole('textbox', { name: /generate\.idealOutput/ })).toHaveValue(
'Updated idea',
)
})
})
@@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import InstructionEditor from '../instruction-editor'
import { GeneratorType } from '../types'
describe('InstructionEditor accessibility', () => {
it('names the editable textbox with its visible instruction label', () => {
render(
<>
<div id="instruction-label">Visible instructions</div>
<InstructionEditor
aria-labelledby="instruction-label"
editorKey="instructions"
value=""
onChange={vi.fn()}
generatorType={GeneratorType.prompt}
availableVars={[]}
availableNodes={[]}
isShowCurrentBlock={false}
isShowLastRunBlock={false}
/>
</>,
)
expect(screen.getByRole('textbox', { name: 'Visible instructions' })).toBeInTheDocument()
})
})
@@ -14,11 +14,11 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useQuery } from '@tanstack/react-query'
import { useBoolean, useSessionStorageState } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/app/configuration/toast'
import Loading from '@/app/components/base/loading'
@@ -80,6 +80,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
onFinished,
}) => {
const { t } = useTranslation()
const instructionLabelId = useId()
const [storedModel, setStoredModel] = useAutoGenModel()
const [selectedModel, setSelectedModel] = React.useState<Model>()
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(
@@ -188,7 +189,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
)
const renderLoading = (
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3">
<div className="flex min-w-0 flex-col items-center justify-center space-y-3 xl:h-full xl:flex-1">
<Loading />
<div className="text-[13px] text-text-tertiary">
{t(($) => $['generate.loading'], { ns: 'appDebug' })}
@@ -280,20 +281,20 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
if (!open) onClose()
}}
>
<DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-285 max-w-none! min-w-285 overflow-hidden! border-none p-0! text-left align-middle">
<div className="flex h-full min-h-0 flex-wrap">
<div className="h-full w-142.5 shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<DialogContent className="max-h-[calc(100dvh-2rem)] w-285 overflow-y-auto border-none p-0! text-left align-middle xl:h-[min(680px,calc(100dvh-2rem))] xl:overflow-hidden">
<div className="flex min-h-0 flex-col xl:h-full xl:flex-row">
<div className="min-w-0 border-divider-regular p-6 xl:h-full xl:flex-1 xl:overflow-y-auto xl:border-r">
<div className="mb-5">
<div className={`text-lg leading-7 font-bold ${s.textGradient}`}>
<DialogTitle className={`text-lg leading-7 font-bold ${s.textGradient}`}>
{t(($) => $['generate.title'], { ns: 'appDebug' })}
</div>
</DialogTitle>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">
{t(($) => $['generate.description'], { ns: 'appDebug' })}
</div>
</div>
<div>
<ModelParameterModal
popupClassName="w-[520px]!"
popupClassName="w-130! max-w-[calc(100vw-2rem)]"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
@@ -333,11 +334,15 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
{/* inputs */}
<div className="mt-4">
<div>
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
<div
id={instructionLabelId}
className="mb-1.5 system-sm-semibold-uppercase text-text-secondary"
>
{t(($) => $['generate.instruction'], { ns: 'appDebug' })}
</div>
{isBasicMode ? (
<InstructionEditorInBasic
aria-labelledby={instructionLabelId}
editorKey={instructionEditorKey}
generatorType={GeneratorType.prompt}
value={instruction}
@@ -349,6 +354,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
/>
) : (
<InstructionEditorInWorkflow
aria-labelledby={instructionLabelId}
editorKey={instructionEditorKey}
generatorType={GeneratorType.prompt}
value={instruction}
@@ -360,7 +366,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
</div>
<IdeaOutput value={ideaOutput} onChange={setIdeaOutput} />
<div className="mt-7 flex justify-end space-x-2">
<div className="mt-7 flex flex-wrap justify-end gap-2">
<Button onClick={onClose}>
{t(($) => $[`${i18nPrefix}.dismiss`], { ns: 'appDebug' })}
</Button>
@@ -380,7 +386,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
</div>
{!isLoading && current && (
<div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0">
<div className="min-w-0 bg-background-default-subtle p-6 pb-0 xl:h-full xl:flex-1">
<Result
current={current!}
isBasicMode={isBasicMode}
@@ -1,8 +1,7 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
@@ -17,28 +16,21 @@ type Props = Readonly<{
const IdeaOutput: FC<Props> = ({ value, onChange }) => {
const { t } = useTranslation()
const [isFoldIdeaOutput, { toggle: toggleFoldIdeaOutput }] = useBoolean(true)
return (
<div className="mt-4 text-[0px]">
<div
className="mb-1.5 flex cursor-pointer items-center text-sm/5 font-medium text-text-primary"
onClick={toggleFoldIdeaOutput}
>
<div className="mr-1 system-sm-semibold-uppercase text-text-secondary">
<Collapsible className="mt-4 text-[0px]">
<CollapsibleTrigger className="group mb-1.5 flex cursor-pointer flex-wrap items-center text-left text-sm/5 font-medium text-text-primary focus-visible:ring-2 focus-visible:ring-components-input-border-active focus-visible:outline-hidden">
<span className="mr-1 system-sm-semibold-uppercase text-text-secondary">
{t(($) => $[`${i18nPrefix}.idealOutput`], { ns: 'appDebug' })}
</div>
<div className="system-xs-regular text-text-tertiary">
</span>
<span className="system-xs-regular text-text-tertiary">
({t(($) => $[`${i18nPrefix}.optional`], { ns: 'appDebug' })})
</div>
</span>
<ArrowDownRoundFill
className={cn(
'size text-text-quaternary',
isFoldIdeaOutput && 'relative top-px -rotate-90',
)}
aria-hidden
className="size relative top-px -rotate-90 text-text-quaternary group-data-panel-open:top-0 group-data-panel-open:rotate-0"
/>
</div>
{!isFoldIdeaOutput && (
</CollapsibleTrigger>
<CollapsiblePanel>
<Textarea
aria-label={t(($) => $[`${i18nPrefix}.idealOutput`], { ns: 'appDebug' })}
className="h-20"
@@ -46,8 +38,8 @@ const IdeaOutput: FC<Props> = ({ value, onChange }) => {
value={value}
onValueChange={(value) => onChange(value)}
/>
)}
</div>
</CollapsiblePanel>
</Collapsible>
)
}
export default React.memo(IdeaOutput)
@@ -13,6 +13,7 @@ import InstructionEditor from './instruction-editor'
type Props = Readonly<{
nodeId: string
value: string
'aria-labelledby'?: string
editorKey: string
onChange: (text: string) => void
generatorType: GeneratorType
@@ -20,6 +21,7 @@ type Props = Readonly<{
}>
const InstructionEditorInWorkflow: FC<Props> = ({
'aria-labelledby': ariaLabelledBy,
nodeId,
value,
editorKey,
@@ -48,6 +50,7 @@ const InstructionEditorInWorkflow: FC<Props> = ({
return (
<InstructionEditor
aria-labelledby={ariaLabelledBy}
value={value}
onChange={onChange}
editorKey={editorKey}
@@ -13,6 +13,7 @@ import { BlockEnum } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
type Props = Readonly<{
'aria-labelledby'?: string
editorKey: string
value: string
onChange: (text: string) => void
@@ -27,6 +28,7 @@ type Props = Readonly<{
const i18nPrefix = 'generate'
const InstructionEditor: FC<Props> = ({
'aria-labelledby': ariaLabelledBy,
editorKey,
generatorType,
value,
@@ -65,6 +67,10 @@ const InstructionEditor: FC<Props> = ({
return (
<div className="relative">
<PromptEditor
aria-labelledby={ariaLabelledBy}
aria-label={
ariaLabelledBy ? undefined : t(($) => $['generate.instruction'], { ns: 'appDebug' })
}
wrapperClassName="border border-components-input-bg-normal! bg-components-input-bg-normal hover:border-components-input-bg-hover! rounded-[10px] px-4 pt-3"
key={editorKey}
instanceId={editorKey}
@@ -7,7 +7,7 @@ import { Generator } from '@/app/components/base/icons/src/vender/other'
const ResPlaceholder: FC = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8">
<div className="flex min-w-0 flex-col items-center justify-center space-y-3 px-8 xl:h-full xl:flex-1">
<Generator className="size-8 text-text-quaternary" />
<div className="text-center text-[13px] leading-5 font-normal text-text-tertiary">
<div>{t(($) => $['generate.newNoDataLine1'], { ns: 'appDebug' })}</div>
@@ -40,7 +40,7 @@ const Result: FC<Props> = ({
return (
<div className="flex h-full flex-col">
<div className="mb-3 flex shrink-0 items-center justify-between">
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-y-3">
<div>
<div className="shrink-0 text-base leading-[160%] font-semibold text-text-secondary">
{t(($) => $['generate.resTitle'], { ns: 'appDebug' })}
@@ -115,9 +115,9 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
<div className="absolute top-0 left-0 h-14 w-full">
<div className="flex h-14 items-center justify-between px-6">
<div className="flex items-center gap-2">
<div className="system-xl-semibold text-text-primary">
<h1 className="system-xl-semibold text-text-primary">
{t(($) => $.orchestrate, { ns: 'appDebug' })}
</div>
</h1>
{showLegacyAgentBadge && <LegacyAgentBadge />}
{isAdvancedMode && (
<div className="flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 system-xs-medium-uppercase text-text-tertiary uppercase">
@@ -294,7 +294,7 @@ const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => {
return (
<FeaturePanel
className="mt-2"
title={t(($) => $['feature.dataSet.title'], { ns: 'appDebug' })}
title={<h2>{t(($) => $['feature.dataSet.title'], { ns: 'appDebug' })}</h2>}
headerRight={
!readonly && (
<div className="flex items-center gap-1">
@@ -422,9 +422,9 @@ const Debug: FC<IDebug> = ({
<>
<Collapsible open={expanded} onOpenChange={setExpanded} render={<div className="shrink-0" />}>
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<div className="system-xl-semibold text-text-primary">
<h2 className="system-xl-semibold text-text-primary">
{t(($) => $['inputs.title'], { ns: 'appDebug' })}
</div>
</h2>
<div className="flex items-center">
{debugWithMultipleModel ? (
<>
@@ -31,14 +31,14 @@ describe('FeatureCard', () => {
it('should render a switch toggle', () => {
render(<FeatureCard {...defaultProps} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
expect(screen.getByRole('switch', { name: 'Test Feature' })).toBeInTheDocument()
})
it('should call onChange when switch is toggled', () => {
const onChange = vi.fn()
render(<FeatureCard {...defaultProps} onChange={onChange} />)
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('switch', { name: 'Test Feature' }))
expect(onChange).toHaveBeenCalledTimes(1)
})
@@ -90,7 +90,7 @@ describe('FeatureCard', () => {
it('should handle disabled state', () => {
render(<FeatureCard {...defaultProps} disabled={true} />)
const switchElement = screen.getByRole('switch')
const switchElement = screen.getByRole('switch', { name: 'Test Feature' })
expect(switchElement).toBeInTheDocument()
})
@@ -98,6 +98,8 @@ describe('FeatureCard', () => {
render(<FeatureCard {...defaultProps} />)
// Should not throw when switch is clicked without onChange
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
expect(() =>
fireEvent.click(screen.getByRole('switch', { name: 'Test Feature' })),
).not.toThrow()
})
})
@@ -130,10 +130,26 @@ describe('NewFeaturePanel', () => {
renderPanel({ show: true })
expect(screen.getByText(/common\.featuresDescription/)).toBeInTheDocument()
expect(screen.getAllByText(/common\.features/).length).toBeGreaterThanOrEqual(1)
expect(screen.getByRole('dialog', { name: 'workflow.common.features' })).toBeInTheDocument()
})
})
it('names each configuration feature switch from its visible title', () => {
renderPanel({ isChatMode: true, inWorkflow: false, showFileUpload: false })
for (const feature of [
'conversationOpener',
'suggestedQuestionsAfterAnswer',
'citation',
'moderation',
'annotation',
]) {
expect(
screen.getByRole('switch', { name: `appDebug.feature.${feature}.title` }),
).toBeInTheDocument()
}
})
describe('Chat Mode Features', () => {
it('should render conversation opener in chat mode', () => {
renderPanel({ isChatMode: true })
@@ -3,14 +3,14 @@ import * as React from 'react'
import { Infotip } from '@/app/components/base/infotip'
type Props = Readonly<{
icon: any
title: any
tooltip?: any
value: any
icon: React.ReactNode
title: React.ReactNode
tooltip?: React.ReactNode
value: boolean | undefined
description?: string
children?: React.ReactNode
disabled?: boolean
onChange?: (state: any) => void
onChange?: (state: boolean) => void
onMouseEnter?: () => void
onMouseLeave?: () => void
}>
@@ -27,6 +27,8 @@ const FeatureCard = ({
onMouseEnter,
onMouseLeave,
}: Props) => {
const titleId = React.useId()
return (
<div
className="mb-1 rounded-xl border-t-[0.5px] border-l-[0.5px] border-effects-highlight bg-background-section-burn p-3"
@@ -36,8 +38,8 @@ const FeatureCard = ({
<div className="mb-2 flex items-center gap-2">
{icon}
<div className="flex grow items-center system-sm-semibold text-text-secondary">
{title}
{tooltip && (
<span id={titleId}>{title}</span>
{!!tooltip && (
<Infotip
aria-label={typeof tooltip === 'string' ? tooltip : String(title)}
className="ml-0.5 size-3.5"
@@ -47,6 +49,7 @@ const FeatureCard = ({
)}
</div>
<Switch
aria-labelledby={titleId}
disabled={disabled}
className="shrink-0"
onCheckedChange={(state) => onChange?.(state)}
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
import { DrawerCloseButton } from '@langgenius/dify-ui/drawer'
import { DrawerCloseButton, DrawerTitle } from '@langgenius/dify-ui/drawer'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply'
@@ -76,9 +76,9 @@ const NewFeaturePanel = ({
{/* header */}
<div className="flex shrink-0 justify-between p-4 pb-3">
<div>
<div className="system-xl-semibold text-text-primary">
<DrawerTitle className="system-xl-semibold text-text-primary">
{title ?? t(($) => $['common.features'], { ns: 'workflow' })}
</div>
</DrawerTitle>
<div className="body-xs-regular text-text-tertiary">
{description ?? t(($) => $['common.featuresDescription'], { ns: 'workflow' })}
</div>
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ModelParameterModal from '../index'
let parameterRules: Array<Record<string, unknown>> | undefined = [
@@ -206,10 +207,14 @@ describe('ModelParameterModal', () => {
]
})
it('should render trigger and open modal content when trigger is clicked', () => {
it('should open settings with the visible title as its accessible name', async () => {
const user = userEvent.setup()
render(<ModelParameterModal {...defaultProps} />)
openSettings()
await user.click(screen.getByRole('button', { name: 'common.modelProvider.modelSettings' }))
expect(
screen.getByRole('dialog', { name: 'common.modelProvider.modelSettings' }),
).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
})
@@ -64,6 +64,29 @@ describe('ParameterItem', () => {
capturedWorkflowNodesMap = undefined
})
it.each([
{ type: 'text', name: 'json_schema', label: 'JSON Schema' },
{ type: 'string', name: 'system_prompt', label: 'System prompt' },
])(
'should name the $type input after its visible parameter label',
async ({ type, name, label }) => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={createRule({ type, name, label: { en_US: label, zh_Hans: label } })}
value=""
onChange={onChange}
/>,
)
await user.type(screen.getByRole('textbox', { name: label }), 'x')
expect(onChange).toHaveBeenCalledWith('x')
expect(screen.getByRole('switch', { name: label })).toBeChecked()
},
)
it('should render float controls and clamp numeric input to max', () => {
const onChange = vi.fn()
render(
@@ -10,7 +10,13 @@ import type { ParameterValue } from './parameter-item'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
@@ -187,9 +193,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
className={cn(popupClassName, 'w-100 rounded-2xl')}
>
<div className="relative px-3 pt-3.5 pb-1">
<div className="pr-8 pl-1 system-xl-semibold text-text-primary">
<PopoverTitle className="pr-8 pl-1 system-xl-semibold text-text-primary">
{t(($) => $['modelProvider.modelSettings'], { ns: 'common' })}
</div>
</PopoverTitle>
<PopoverClose
render={
<IconButton
@@ -343,6 +343,7 @@ function ParameterItem({
return (
<input
aria-labelledby={labelId}
className={cn(
isInWorkflow ? 'w-37.5' : 'w-full',
'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 system-sm-regular text-components-input-text-filled outline-hidden',
@@ -377,6 +378,7 @@ function ParameterItem({
return (
<textarea
aria-labelledby={labelId}
className="ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 system-sm-regular text-components-input-text-filled"
value={renderValue as string}
onChange={handleStringInputChange}
@@ -15,7 +15,8 @@ import {
import { ModelSelectorTrigger } from '../model-selector-trigger'
const render = (node: ReactNode) => renderComponent(<Popover>{node}</Popover>)
const getTrigger = () => screen.getByRole('button', { name: 'plugin.detailPanel.configureModel' })
const getTrigger = () =>
screen.getByRole('button', { name: /GPT-4|plugin.detailPanel.configureModel/ })
const mockUseQuery = vi.hoisted(() => vi.fn())
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
@@ -90,6 +91,7 @@ describe('ModelSelectorTrigger', () => {
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.getByText('CHAT')).toBeInTheDocument()
expect(getTrigger()).toHaveAccessibleName('GPT-4 CHAT')
expect(getTrigger()).toBeEnabled()
})
@@ -98,6 +100,11 @@ describe('ModelSelectorTrigger', () => {
expect(screen.getByText('legacy-model')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: 'legacy-model common.modelProvider.selector.incompatible',
}),
).toBeEnabled()
})
})
@@ -98,7 +98,6 @@ function ModelSelectorTrigger({
render={
<button
type="button"
aria-label={t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
data-deprecated={isDeprecated ? '' : undefined}
data-model-status={status}
data-shape={shape}