mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
chore: improve accessibility of basic app orchestration pages (#42428)
This commit is contained in:
38 files changed
+679
-301
No files matched your search
@@ -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 })} />)
|
||||
|
||||
+31
@@ -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()
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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])
|
||||
|
||||
+54
-46
@@ -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>
|
||||
|
||||
+117
@@ -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()
|
||||
},
|
||||
)
|
||||
})
|
||||
+2
@@ -141,6 +141,8 @@ describe('GetAutomaticRes', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /generate\.title/ })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)generate\.template\.pythonDebugger\.name(?=$|:)/))
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
+32
-16
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
+25
@@ -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)
|
||||
+3
@@ -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>
|
||||
|
||||
+7
-2
@@ -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()
|
||||
})
|
||||
|
||||
+23
@@ -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(
|
||||
|
||||
+9
-3
@@ -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
|
||||
|
||||
+2
@@ -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}
|
||||
|
||||
+8
-1
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
-1
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user