mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
chore: improve a11y of trigger integration, agent strategy and extension page (#43023)
This commit is contained in:
29 files changed
+426
-210
No files matched your search
@@ -2209,22 +2209,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/strategy-item.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -2249,31 +2233,11 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-detail-panel/trigger/event-list.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-item/index.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -4711,4 +4675,4 @@
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,32 @@ describe('TimePicker', () => {
|
||||
expect(screen.getByTestId('state-trigger')).toHaveAttribute('data-popup-open')
|
||||
})
|
||||
|
||||
it('supports one named native button without an inner text field for a custom trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="10:45 AM"
|
||||
timezone="UTC"
|
||||
triggerNativeButton
|
||||
renderTrigger={(props, _state, { displayText }) => (
|
||||
<button {...props} type="button">
|
||||
<span className="sr-only">Update Time</span>
|
||||
<span>{displayText}</span>
|
||||
<span>UTC</span>
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Update Time 10:45 AM UTC' })
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
|
||||
trigger.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(trigger).toHaveAttribute('data-popup-open')
|
||||
})
|
||||
|
||||
it('should render with notClearable prop without errors', () => {
|
||||
render(<TimePicker {...baseProps} notClearable={true} value="10:00 AM" timezone="UTC" />)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const TimePicker = ({
|
||||
onChange,
|
||||
onClear,
|
||||
renderTrigger,
|
||||
triggerNativeButton = false,
|
||||
title,
|
||||
minuteFilter,
|
||||
notClearable = false,
|
||||
@@ -193,11 +194,14 @@ const TimePicker = ({
|
||||
)
|
||||
|
||||
const displayValue = formatTimeValue(value)
|
||||
const placeholderText = placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' })
|
||||
const getDisplayText = (open: boolean) =>
|
||||
open && isDayjsObject(selectedTime)
|
||||
? selectedTime.format(timeFormat)
|
||||
: displayValue || placeholderText
|
||||
const renderInput = (open: boolean) => {
|
||||
const placeholderDate =
|
||||
open && isDayjsObject(selectedTime)
|
||||
? selectedTime.format(timeFormat)
|
||||
: placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' })
|
||||
open && isDayjsObject(selectedTime) ? selectedTime.format(timeFormat) : placeholderText
|
||||
|
||||
return (
|
||||
<input
|
||||
@@ -212,12 +216,16 @@ const TimePicker = ({
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
nativeButton={triggerNativeButton}
|
||||
className={triggerFullWidth ? 'flex! w-full' : undefined}
|
||||
render={(props, state) => {
|
||||
const inputElem = renderInput(state.open)
|
||||
if (renderTrigger)
|
||||
return renderTrigger(props, state, { inputElem, onClick: handleClickTrigger })
|
||||
return renderTrigger(props, state, {
|
||||
inputElem,
|
||||
displayText: getDisplayText(state.open),
|
||||
onClick: handleClickTrigger,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -63,6 +63,7 @@ export type DatePickerFooterProps = {
|
||||
|
||||
export type TriggerParams = {
|
||||
inputElem: React.ReactNode
|
||||
displayText: string
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
export type TimePickerProps = {
|
||||
@@ -77,6 +78,7 @@ export type TimePickerProps = {
|
||||
state: TriggerState,
|
||||
params: TriggerParams,
|
||||
) => React.ReactElement
|
||||
triggerNativeButton?: boolean
|
||||
title?: string
|
||||
minuteFilter?: (minutes: string[]) => string[]
|
||||
notClearable?: boolean
|
||||
|
||||
+5
-3
@@ -548,7 +548,9 @@ describe('ModelProviderPage', () => {
|
||||
screen.getByRole('radiogroup', { name: 'plugin.autoUpdate.autoUpdate' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.autoUpdate.scope')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.autoUpdate.updateTime')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /plugin\.autoUpdate\.updateTime/ }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.autoUpdate.changeTimezone')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'plugin.autoUpdate.strategy.fixOnly.name' }),
|
||||
@@ -604,12 +606,12 @@ describe('ModelProviderPage', () => {
|
||||
renderModelProviderPage()
|
||||
|
||||
openUpdateSettings()
|
||||
fireEvent.click(screen.getByDisplayValue('12:00 AM'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /12:00 AM/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '01' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '15' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'time.operation.ok' }))
|
||||
|
||||
expect(screen.getByDisplayValue('01:15 AM')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /01:15 AM/ })).toBeInTheDocument()
|
||||
|
||||
saveUpdateSettings()
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useId, useState } from 'react'
|
||||
import { AppSelector } from '@/app/components/plugins/plugin-detail-panel/app-selector'
|
||||
import ModelParameterModal from '@/app/components/plugins/plugin-detail-panel/model-selector'
|
||||
import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector'
|
||||
@@ -110,6 +110,7 @@ function Form<
|
||||
availableNodes,
|
||||
}: FormProps<CustomFormSchema>) {
|
||||
const language = useLanguage()
|
||||
const formId = useId()
|
||||
const [changeKey, setChangeKey] = useState('')
|
||||
const filteredProps: Omit<FormProps<CustomFormSchema>, 'override' | 'customRenderField'> = {
|
||||
className,
|
||||
@@ -196,6 +197,7 @@ function Form<
|
||||
|
||||
const disabled =
|
||||
readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name'))
|
||||
const inputId = `${formId}-${variable}`
|
||||
return (
|
||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||
<div
|
||||
@@ -204,11 +206,18 @@ function Form<
|
||||
'flex items-center py-2 system-sm-semibold text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{label[language] || label.en_US}
|
||||
{required && <span className="ml-1 text-red-500">*</span>}
|
||||
<label htmlFor={inputId}>
|
||||
{label[language] || label.en_US}
|
||||
{required && (
|
||||
<span aria-hidden className="ml-1 text-red-500">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{infotipContent}
|
||||
</div>
|
||||
<Input
|
||||
id={inputId}
|
||||
className={cn(inputClassName, `${disabled && 'cursor-not-allowed opacity-60'}`)}
|
||||
value={
|
||||
isShowDefaultValue &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
type InputProps = {
|
||||
id?: string
|
||||
value?: string
|
||||
onChange: (v: string) => void
|
||||
onFocus?: () => void
|
||||
@@ -14,6 +15,7 @@ type InputProps = {
|
||||
}
|
||||
|
||||
const Input: FC<InputProps> = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
@@ -38,6 +40,7 @@ const Input: FC<InputProps> = ({
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
tabIndex={0}
|
||||
// Do not set autoComplete for security - prevents browser from storing sensitive API keys
|
||||
className={`block h-8 w-full appearance-none rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-sm text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-sm placeholder:text-text-tertiary hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs ${validated ? 'pr-7.5' : ''} ${className || ''} `}
|
||||
|
||||
+3
@@ -217,6 +217,9 @@ describe('Form', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByPlaceholderText('API Key'))!.toHaveValue('default-key')
|
||||
expect(screen.getByRole('textbox', { name: 'API Key' })).toHaveValue('default-key')
|
||||
expect(screen.getByLabelText('Secret')).toHaveValue('top-secret')
|
||||
expect(screen.getByRole('spinbutton', { name: 'Limit' })).toHaveValue(5)
|
||||
expect(screen.getByPlaceholderText('Secret'))!.toHaveValue('top-secret')
|
||||
expect(screen.getByPlaceholderText('Limit'))!.toHaveValue(5)
|
||||
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
|
||||
|
||||
@@ -163,6 +163,7 @@ const UpdateSettingDialogForm = ({
|
||||
title={t(($) => $['autoUpdate.updateTime'], { ns: 'plugin' })}
|
||||
minuteFilter={minuteFilter}
|
||||
renderTrigger={renderTimePickerTrigger}
|
||||
triggerNativeButton
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
|
||||
@@ -209,7 +209,7 @@ const UpdateSettingDialog = ({ category, disabled = false }: Props) => {
|
||||
setIsOpen(false)
|
||||
}, [autoUpgrade, saveAutoUpgrade])
|
||||
const renderTimePickerTrigger = useCallback<NonNullable<TimePickerProps['renderTrigger']>>(
|
||||
(props, state, { inputElem, onClick }: TriggerParams) => {
|
||||
(props, state, { displayText, onClick }: TriggerParams) => {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
@@ -230,8 +230,9 @@ const UpdateSettingDialog = ({ category, disabled = false }: Props) => {
|
||||
state.open ? 'text-text-secondary' : 'group-hover:text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t(($) => $['autoUpdate.updateTime'], { ns: 'plugin' })}</span>
|
||||
<span className="min-w-0 flex-1 p-1 system-sm-regular text-components-input-text-filled">
|
||||
{inputElem}
|
||||
{displayText}
|
||||
</span>
|
||||
<span className="shrink-0 pr-0.5 system-sm-regular text-text-tertiary">
|
||||
{convertTimezoneToOffsetStr(timezone)}
|
||||
@@ -239,7 +240,7 @@ const UpdateSettingDialog = ({ category, disabled = false }: Props) => {
|
||||
</button>
|
||||
)
|
||||
},
|
||||
[timezone],
|
||||
[t, timezone],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -83,6 +83,18 @@ describe('Endpoint management', () => {
|
||||
expect(requestedURL.searchParams.get('page_size')).toBe('100')
|
||||
})
|
||||
|
||||
it('lets keyboard users reach the URL copy action from the endpoint switch', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderEndpoints()
|
||||
const toggle = await screen.findByRole('switch', { name: 'Endpoint 1' })
|
||||
const copy = screen.getByRole('button', {
|
||||
name: 'common.operation.copy https://api.example.com/public',
|
||||
})
|
||||
toggle.focus()
|
||||
await user.tab({ shift: true })
|
||||
expect(copy).toHaveFocus()
|
||||
})
|
||||
|
||||
it('creates through the canonical route and refreshes the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
endpoints = []
|
||||
@@ -111,7 +123,9 @@ describe('Endpoint management', () => {
|
||||
it('updates through the canonical item route without dropping falsy settings', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'common.operation.edit Endpoint 1' }),
|
||||
)
|
||||
await user.clear(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Saved endpoint')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
@@ -131,7 +145,7 @@ describe('Endpoint management', () => {
|
||||
it('keeps enabled state when disabling is cancelled, then follows refetched state after confirm', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('switch'))
|
||||
await user.click(await screen.findByRole('switch', { name: 'Endpoint 1' }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(mutations).toHaveLength(0)
|
||||
@@ -149,7 +163,9 @@ describe('Endpoint management', () => {
|
||||
it('deletes through the canonical route and refreshes the empty state', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'common.operation.delete Endpoint 1' }),
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
expect(await screen.findByText('plugin.detailPanel.endpointsEmpty')).toBeInTheDocument()
|
||||
expect(mutations).toEqual([
|
||||
@@ -161,7 +177,9 @@ describe('Endpoint management', () => {
|
||||
const user = userEvent.setup()
|
||||
rejectMutation = true
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'common.operation.edit Endpoint 1' }),
|
||||
)
|
||||
await user.clear(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Saved endpoint')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
@@ -182,7 +200,9 @@ describe('Endpoint management', () => {
|
||||
renderEndpoints()
|
||||
if (action === 'disable') await user.click(await screen.findByRole('switch'))
|
||||
else
|
||||
await user.click(await screen.findByRole('button', { name: `common.operation.${action}` }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: `common.operation.${action} Endpoint 1` }),
|
||||
)
|
||||
const actionName = action === 'edit' ? 'common.operation.save' : 'common.operation.confirm'
|
||||
const submit = screen.getByRole('button', { name: actionName })
|
||||
await user.click(submit)
|
||||
@@ -219,7 +239,9 @@ describe('Endpoint management', () => {
|
||||
rejectMutation = true
|
||||
failAfterWrite = true
|
||||
renderEndpoints()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.edit' }))
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'common.operation.edit Endpoint 1' }),
|
||||
)
|
||||
await user.clear(screen.getByPlaceholderText('Endpoint Name'))
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'Submitted name')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
@@ -5,8 +5,6 @@ import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import EndpointModal from '../endpoint-modal'
|
||||
import { createPluginDetail } from './endpoint-fixture'
|
||||
|
||||
const { showError } = vi.hoisted(() => ({ showError: vi.fn() }))
|
||||
vi.mock('@/app/notifications', () => ({ toast: { error: showError } }))
|
||||
vi.mock('../../readme-panel/entrance', () => ({ ReadmeEntrance: () => null }))
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (label: { en_US: string }) => label.en_US,
|
||||
@@ -31,13 +29,17 @@ describe('Endpoint settings form', () => {
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: 'plugin.detailPanel.endpointModalTitle' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'Endpoint Name' })).toBeInTheDocument()
|
||||
await user.type(screen.getByPlaceholderText('Endpoint Name'), 'New endpoint')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).toHaveBeenCalledWith({
|
||||
name: 'New endpoint',
|
||||
settings: { enabled: false, limit: '0' },
|
||||
})
|
||||
expect(showError).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -119,7 +121,8 @@ describe('Endpoint settings form', () => {
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
expect(showError).toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('common.errorMsg.fieldRequired')
|
||||
expect(screen.getByRole('alert')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('preserves explicit clearing and does not mutate edit values', async () => {
|
||||
@@ -179,8 +182,11 @@ describe('Endpoint settings form', () => {
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
expect(showError).toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Endpoint Name')
|
||||
expect(screen.getByRole('alert')).toHaveFocus()
|
||||
expect(screen.getByPlaceholderText('Endpoint Name')).toBeInTheDocument()
|
||||
await user.type(screen.getByRole('textbox', { name: 'Endpoint Name' }), 'New endpoint')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps pending save focusable and blocks click and implicit submissions', async () => {
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('Strategy details contract', () => {
|
||||
expect(drawer.getByText('boolean')).toBeInTheDocument()
|
||||
expect(drawer.getByText('tools.setBuiltInTools.file')).toBeInTheDocument()
|
||||
expect(drawer.getByText('multiple-tool-select')).toBeInTheDocument()
|
||||
expect(drawer.getByText('workflow.common.output')).toBeInTheDocument()
|
||||
expect(drawer.getByText('String')).toBeInTheDocument()
|
||||
expect(drawer.getByText('Array[String]')).toBeInTheDocument()
|
||||
expect(drawer.getByText('Result output')).toBeInTheDocument()
|
||||
@@ -72,7 +73,7 @@ describe('Strategy details contract', () => {
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Strategy Label' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Search query')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('OUTPUT')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.output')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('safely displays JSON schema properties that do not declare a simple type', () => {
|
||||
|
||||
@@ -97,7 +97,10 @@ describe('Plugin agent strategies', () => {
|
||||
})
|
||||
render(<AgentStrategyList detail={plugin} />)
|
||||
|
||||
await user.click(await screen.findByText('Strategy Label'))
|
||||
const strategyButton = await screen.findByRole('button', { name: 'Strategy Label' })
|
||||
expect(strategyButton).toHaveAccessibleDescription('Strategy description')
|
||||
strategyButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
const drawer = await screen.findByRole('dialog', { name: 'Strategy Label' })
|
||||
expect(within(drawer).getByText('Test Provider')).toBeInTheDocument()
|
||||
expect(within(drawer).getByText('Query')).toBeInTheDocument()
|
||||
@@ -108,9 +111,15 @@ describe('Plugin agent strategies', () => {
|
||||
|
||||
await user.click(within(drawer).getByRole('button', { name: 'common.operation.close' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await user.click(screen.getByText('Strategy Label'))
|
||||
await user.click(screen.getByText('BACK'))
|
||||
expect(strategyButton).toHaveFocus()
|
||||
await user.click(strategyButton)
|
||||
const backButton = within(
|
||||
await screen.findByRole('dialog', { name: 'Strategy Label' }),
|
||||
).getByRole('button', { name: 'plugin.detailPanel.operation.back' })
|
||||
backButton.focus()
|
||||
await user.keyboard(' ')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(strategyButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('allows an empty provider declaration without fabricating strategies', async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ type Props = Readonly<{
|
||||
|
||||
const EndpointCard = ({ pluginDetail, data }: Props) => {
|
||||
const { t } = useTranslation(['common', 'plugin'])
|
||||
const endpointNameId = React.useId()
|
||||
const endpointID = data.id
|
||||
const [isShowDisableConfirm, { setTrue: showDisableConfirm, setFalse: hideDisableConfirm }] =
|
||||
useBoolean(false)
|
||||
@@ -94,17 +95,17 @@ const EndpointCard = ({ pluginDetail, data }: Props) => {
|
||||
<div className="flex items-center">
|
||||
<div className="mb-1 flex h-6 grow items-center gap-1 system-md-semibold text-text-secondary">
|
||||
<span aria-hidden className="i-ri-login-circle-line size-4" />
|
||||
<div>{data.name}</div>
|
||||
<div id={endpointNameId}>{data.name}</div>
|
||||
</div>
|
||||
<div className="flex w-0 items-center overflow-hidden opacity-0 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100 focus-within:w-auto focus-within:overflow-visible focus-within:opacity-100">
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.edit'], { ns: 'common' })}
|
||||
aria-label={`${t(($) => $['operation.edit'], { ns: 'common' })} ${data.name}`}
|
||||
onClick={showEndpointModalConfirm}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
|
||||
aria-label={`${t(($) => $['operation.delete'], { ns: 'common' })} ${data.name}`}
|
||||
tone="destructive"
|
||||
onClick={showDeleteConfirm}
|
||||
>
|
||||
@@ -125,8 +126,8 @@ const EndpointCard = ({ pluginDetail, data }: Props) => {
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={copyLabel}
|
||||
className="ml-2 hidden shrink-0 group-hover/item:flex"
|
||||
aria-label={`${copyLabel} ${data.url}${endpoint.path}`}
|
||||
className="ml-2 shrink-0 opacity-0 group-focus-within/item:opacity-100 group-hover/item:opacity-100"
|
||||
onClick={() => handleCopy(`${data.url}${endpoint.path}`)}
|
||||
>
|
||||
{isCopied ? (
|
||||
@@ -163,6 +164,7 @@ const EndpointCard = ({ pluginDetail, data }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
<Switch
|
||||
aria-labelledby={endpointNameId}
|
||||
className="ml-3"
|
||||
checked={data.enabled}
|
||||
onCheckedChange={handleSwitch}
|
||||
|
||||
@@ -18,13 +18,13 @@ import {
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
||||
import { toast } from '@/app/notifications'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { FormTypeEnum } from '../../header/account-setting/model-provider-page/declarations'
|
||||
import { ReadmeEntrance } from '../readme-panel/entrance'
|
||||
@@ -105,6 +105,8 @@ const EndpointModal = ({
|
||||
const { t } = useTranslation(['common', 'plugin', 'tools'])
|
||||
const fields = [NAME_FIELD, ...settings]
|
||||
const formSchemas = fields.map(toFormSchema)
|
||||
const [validationError, setValidationError] = React.useState<{ message: string } | null>(null)
|
||||
const validationErrorRef = React.useRef<HTMLParagraphElement>(null)
|
||||
const [tempCredential, setTempCredential] = React.useState<Record<string, unknown>>(() => {
|
||||
const values: Record<string, unknown> = {
|
||||
...Object.fromEntries(
|
||||
@@ -124,17 +126,21 @@ const EndpointModal = ({
|
||||
return values
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (validationError) validationErrorRef.current?.focus()
|
||||
}, [validationError])
|
||||
|
||||
const handleSave = () => {
|
||||
if (isPending) return
|
||||
for (const field of fields) {
|
||||
const value = tempCredential[field.name]
|
||||
if (field.required && (value === undefined || value === null || value === '')) {
|
||||
toast.error(
|
||||
t(($) => $['errorMsg.fieldRequired'], {
|
||||
setValidationError({
|
||||
message: t(($) => $['errorMsg.fieldRequired'], {
|
||||
ns: 'common',
|
||||
field: field.label ? getValueFromI18nObject(toFormLabel(field.label)) : field.name,
|
||||
}),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -163,9 +169,9 @@ const EndpointModal = ({
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pb-0">
|
||||
<div className="p-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">
|
||||
<DrawerTitle className="system-xl-semibold text-text-primary">
|
||||
{t(($) => $['detailPanel.endpointModalTitle'], { ns: 'plugin' })}
|
||||
</div>
|
||||
</DrawerTitle>
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
onClick={onCancel}
|
||||
@@ -186,10 +192,21 @@ const EndpointModal = ({
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-2">
|
||||
{validationError && (
|
||||
<p
|
||||
ref={validationErrorRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="mb-2 system-sm-regular text-text-destructive"
|
||||
>
|
||||
{validationError.message}
|
||||
</p>
|
||||
)}
|
||||
<Form
|
||||
value={tempCredential}
|
||||
onChange={(v) => {
|
||||
setTempCredential(v)
|
||||
setValidationError(null)
|
||||
}}
|
||||
formSchemas={formSchemas}
|
||||
isEditMode={true}
|
||||
|
||||
@@ -46,7 +46,7 @@ const getOutputType = (schema: unknown): string => {
|
||||
|
||||
const StrategyDetail: FC<Props> = ({ provider, tenantId, detail, onHide }) => {
|
||||
const getValueFromI18nObject = useRenderI18nObject()
|
||||
const { t } = useTranslation(['common', 'tools'])
|
||||
const { t } = useTranslation(['common', 'tools', 'plugin', 'workflow'])
|
||||
|
||||
const outputSchema = useMemo(() => {
|
||||
const properties = detail.output_schema?.properties
|
||||
@@ -97,13 +97,14 @@ const StrategyDetail: FC<Props> = ({ provider, tenantId, detail, onHide }) => {
|
||||
<RiCloseLine aria-hidden="true" className="size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary"
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 flex min-h-6 cursor-pointer items-center gap-1 rounded-sm system-xs-semibold-uppercase text-text-accent-secondary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={onHide}
|
||||
>
|
||||
<RiArrowLeftLine className="size-4" />
|
||||
BACK
|
||||
</div>
|
||||
<RiArrowLeftLine aria-hidden="true" className="size-4" />
|
||||
{t(($) => $['detailPanel.operation.back'], { ns: 'plugin' })}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon
|
||||
size="tiny"
|
||||
@@ -161,7 +162,7 @@ const StrategyDetail: FC<Props> = ({ provider, tenantId, detail, onHide }) => {
|
||||
<Separator className="my-2 h-[0.5px]" />
|
||||
</div>
|
||||
<div className="p-4 pb-1 system-sm-semibold-uppercase text-text-primary">
|
||||
OUTPUT
|
||||
{t(($) => $['common.output'], { ns: 'workflow' })}
|
||||
</div>
|
||||
{outputSchema.length > 0 && (
|
||||
<div className="space-y-1 px-4 py-2">
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import StrategyDetailPanel from './strategy-detail'
|
||||
|
||||
@@ -19,22 +19,28 @@ type Props = Readonly<{
|
||||
const StrategyItem = ({ provider, tenantId, detail }: Props) => {
|
||||
const getValueFromI18nObject = useRenderI18nObject()
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
const description = getValueFromI18nObject(detail.description)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
className={cn(
|
||||
'bg-components-panel-item-bg mb-2 cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
'bg-components-panel-item-bg mb-2 w-full cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 text-left shadow-xs outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)}
|
||||
onClick={() => setShowDetail(true)}
|
||||
>
|
||||
<div className="pb-0.5 system-md-semibold text-text-secondary">
|
||||
<div id={titleId} className="pb-0.5 system-md-semibold text-text-secondary">
|
||||
{getValueFromI18nObject(detail.identity.label)}
|
||||
</div>
|
||||
<div className="line-clamp-2 system-xs-regular text-text-tertiary">
|
||||
{getValueFromI18nObject(detail.description)}
|
||||
<div id={descriptionId} className="line-clamp-2 system-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{showDetail && (
|
||||
<StrategyDetailPanel
|
||||
provider={provider}
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
import type { TriggerEvent } from '@/app/components/plugins/types'
|
||||
import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { EventDetailDrawer } from '../event-detail-drawer'
|
||||
@@ -205,7 +205,8 @@ describe('EventDetailDrawer', () => {
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when back clicked', () => {
|
||||
it('should expose a focusable named back button that closes when activated', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<EventDetailDrawer
|
||||
eventInfo={mockEventInfo}
|
||||
@@ -214,7 +215,10 @@ describe('EventDetailDrawer', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('plugin.detailPanel.operation.back'))
|
||||
const backButton = screen.getByRole('button', { name: 'plugin.detailPanel.operation.back' })
|
||||
backButton.focus()
|
||||
expect(backButton).toHaveFocus()
|
||||
await user.click(backButton)
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TriggerEvent } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { TriggerEventsList } from '../event-list'
|
||||
|
||||
@@ -104,6 +105,20 @@ describe('TriggerEventsList', () => {
|
||||
expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens an event through its named keyboard-focusable button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TriggerEventsList />)
|
||||
|
||||
const eventButton = screen.getByRole('button', { name: 'Event One' })
|
||||
expect(eventButton).toHaveAccessibleDescription('Event one description')
|
||||
|
||||
await user.tab()
|
||||
expect(eventButton).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide detail drawer when close clicked', () => {
|
||||
render(<TriggerEventsList />)
|
||||
|
||||
|
||||
@@ -107,13 +107,14 @@ export const EventDetailDrawer: FC<EventDetailDrawerProps> = (props) => {
|
||||
<RiCloseLine aria-hidden="true" className="size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary"
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 flex cursor-pointer items-center gap-1 rounded-sm system-xs-semibold-uppercase text-text-accent-secondary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={onClose}
|
||||
>
|
||||
<RiArrowLeftLine className="size-4" />
|
||||
<RiArrowLeftLine aria-hidden="true" className="size-4" />
|
||||
{t(($) => $['detailPanel.operation.back'], { ns: 'plugin' })}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon size="tiny" className="size-6" src={providerInfo.icon!} />
|
||||
<OrgInfo
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TriggerEvent } from '@/app/components/plugins/types'
|
||||
import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useState } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useTriggerProviderInfo } from '@/service/use-triggers'
|
||||
@@ -17,19 +17,28 @@ const TriggerEventCard = ({ eventInfo, providerInfo }: TriggerEventCardProps) =>
|
||||
const { identity, description } = eventInfo
|
||||
const language = useLanguage()
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
const title = identity.label?.[language] ?? identity.label?.en_US ?? ''
|
||||
const descriptionText = description?.[language] ?? description?.en_US ?? ''
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionText ? descriptionId : undefined}
|
||||
className={cn(
|
||||
'bg-components-panel-item-bg cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
'bg-components-panel-item-bg w-full cursor-pointer rounded-xl border-[0.5px] border-components-panel-border-subtle px-4 py-3 text-left shadow-xs outline-hidden hover:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
)}
|
||||
onClick={() => setShowDetail(true)}
|
||||
>
|
||||
<div className="pb-0.5 system-md-semibold text-text-secondary">{title}</div>
|
||||
<div className="line-clamp-2 system-xs-regular text-text-tertiary">{descriptionText}</div>
|
||||
</div>
|
||||
<div id={titleId} className="pb-0.5 system-md-semibold text-text-secondary">
|
||||
{title}
|
||||
</div>
|
||||
<div id={descriptionId} className="line-clamp-2 system-xs-regular text-text-tertiary">
|
||||
{descriptionText}
|
||||
</div>
|
||||
</button>
|
||||
{showDetail && (
|
||||
<EventDetailDrawer
|
||||
eventInfo={eventInfo}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { PluginDeclaration, PluginDetail } from '../../types'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { PluginCategoryEnum, PluginSource } from '../../types'
|
||||
@@ -584,20 +585,42 @@ describe('PluginItem', () => {
|
||||
|
||||
// ==================== User Interactions Tests ====================
|
||||
describe('User Interactions', () => {
|
||||
it('should select the plugin when its card is clicked', () => {
|
||||
it('should select the plugin with the named card button using Enter or Space', async () => {
|
||||
// Arrange
|
||||
const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' })
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
const { container } = render(<PluginItem plugin={plugin} />)
|
||||
const pluginContainer = container.firstChild as HTMLElement
|
||||
fireEvent.click(pluginContainer)
|
||||
render(<PluginItem plugin={plugin} />)
|
||||
const cardButton = screen.getByRole('button', { name: 'Test Plugin' })
|
||||
await user.tab()
|
||||
expect(cardButton).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
await user.keyboard(' ')
|
||||
|
||||
// Assert
|
||||
expect(mockSetSelectedItem).toHaveBeenCalledWith({
|
||||
expect(mockSetSelectedItem).toHaveBeenNthCalledWith(1, {
|
||||
type: 'plugin',
|
||||
id: 'test-plugin-id',
|
||||
})
|
||||
expect(mockSetSelectedItem).toHaveBeenNthCalledWith(2, {
|
||||
type: 'plugin',
|
||||
id: 'test-plugin-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep source links and actions separate from the card button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PluginItem plugin={createPluginDetail()} />)
|
||||
|
||||
const cardButton = screen.getByRole('button', { name: 'Test Plugin' })
|
||||
const marketplaceLink = screen.getByRole('link')
|
||||
const deleteButton = screen.getByTestId('delete-button')
|
||||
expect(cardButton).not.toContainElement(marketplaceLink)
|
||||
expect(cardButton).not.toContainElement(deleteButton)
|
||||
|
||||
await user.click(deleteButton)
|
||||
expect(mockSetSelectedItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should highlight selected plugin', () => {
|
||||
|
||||
@@ -105,6 +105,7 @@ const PluginItem: FC<Props> = ({
|
||||
|
||||
const getValueFromI18nObject = useRenderI18nObject()
|
||||
const title = getValueFromI18nObject(label)
|
||||
const titleId = React.useId()
|
||||
const descriptionText = getValueFromI18nObject(description)
|
||||
const { data: enable_marketplace } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
@@ -120,17 +121,20 @@ const PluginItem: FC<Props> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/plugin-item relative flex min-w-[min(100%,496px)] flex-1 cursor-pointer flex-col overflow-hidden rounded-xl p-0.75',
|
||||
'group/plugin-item relative flex min-w-[min(100%,496px)] flex-1 flex-col overflow-hidden rounded-xl p-0.75',
|
||||
selectedPluginID === plugin_id &&
|
||||
"after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:inset-ring-[1.5px] after:inset-ring-components-option-card-option-selected-border after:content-['']",
|
||||
source === PluginSource.debugging
|
||||
? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]'
|
||||
: 'bg-background-section-burn',
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedItem({ type: 'plugin', id: plugin.plugin_id })
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-labelledby={titleId}
|
||||
className="absolute inset-0 z-10 cursor-pointer rounded-xl border-0 bg-transparent p-0 outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
|
||||
onClick={() => setSelectedItem({ type: 'plugin', id: plugin.plugin_id })}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-3',
|
||||
@@ -152,7 +156,9 @@ const PluginItem: FC<Props> = ({
|
||||
</div>
|
||||
<div className="ml-3 w-0 grow">
|
||||
<div className="flex h-5 items-center">
|
||||
<Title title={title} />
|
||||
<div id={titleId} className="min-w-0">
|
||||
<Title title={title} />
|
||||
</div>
|
||||
{verified && (
|
||||
<Verified
|
||||
className="ml-0.5 size-4"
|
||||
@@ -167,7 +173,7 @@ const PluginItem: FC<Props> = ({
|
||||
ns: 'plugin',
|
||||
minimalDifyVersion: declarationMeta.minimum_dify_version ?? '0.0.0',
|
||||
})}
|
||||
className="ml-0.5 inline-flex size-4 shrink-0 border-0 bg-transparent p-0"
|
||||
className="relative z-20 ml-0.5 inline-flex size-4 shrink-0 border-0 bg-transparent p-0"
|
||||
>
|
||||
<RiErrorWarningLine color="red" className="size-4 text-text-accent" />
|
||||
</PopoverTrigger>
|
||||
@@ -195,10 +201,7 @@ const PluginItem: FC<Props> = ({
|
||||
text={descriptionText}
|
||||
descriptionLineRows={1}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 right-0 -translate-y-1/2 opacity-0 transition-opacity group-focus-within/plugin-item:pointer-events-auto group-focus-within/plugin-item:opacity-100 group-hover/plugin-item:pointer-events-auto group-hover/plugin-item:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="pointer-events-none absolute top-1/2 right-0 z-20 -translate-y-1/2 opacity-0 transition-opacity group-focus-within/plugin-item:pointer-events-auto group-focus-within/plugin-item:opacity-100 group-hover/plugin-item:pointer-events-auto group-hover/plugin-item:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
|
||||
<Action
|
||||
pluginUniqueIdentifier={plugin_unique_identifier}
|
||||
installationId={installation_id}
|
||||
@@ -247,7 +250,7 @@ const PluginItem: FC<Props> = ({
|
||||
<a
|
||||
href={`https://github.com/${meta!.repo}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-1"
|
||||
className="relative z-20 flex items-center gap-1"
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $.from, { ns: 'plugin' })}
|
||||
@@ -265,7 +268,7 @@ const PluginItem: FC<Props> = ({
|
||||
<a
|
||||
href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })}
|
||||
target="_blank"
|
||||
className="flex items-center gap-0.5"
|
||||
className="relative z-20 flex items-center gap-0.5"
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $.from, { ns: 'plugin' })}{' '}
|
||||
|
||||
+22
-7
@@ -52,14 +52,29 @@ describe('CategoriesFilter', () => {
|
||||
expect(screen.getByText('+1'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clear all selections when clear button clicked', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||
it('clears categories through a separate keyboard control and restores focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
function Harness() {
|
||||
const [categories, setCategories] = React.useState(['tool'])
|
||||
return <CategoriesFilter value={categories} onChange={setCategories} />
|
||||
}
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /Tool/ })
|
||||
const clearSvg = trigger.querySelector('svg')
|
||||
fireEvent.click(clearSvg!)
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
render(<Harness />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Tool' })
|
||||
const clearButton = screen.getByRole('button', {
|
||||
name: 'plugin.clearSearch:{"label":"plugin.allCategories"}',
|
||||
})
|
||||
expect(trigger).not.toContainElement(clearButton)
|
||||
|
||||
await user.tab()
|
||||
expect(trigger).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(clearButton).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(clearButton).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'plugin.allCategories' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should render category options in dropdown', () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('FilterManagement', () => {
|
||||
render(<FilterManagement onFilterChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Models/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Agent/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Agent' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
+27
-8
@@ -1,4 +1,6 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import TagFilter from '../tag-filter'
|
||||
|
||||
@@ -40,7 +42,7 @@ describe('TagFilter', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Agent/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Agent' }))
|
||||
const portal = await screen.findByRole('dialog')
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), {
|
||||
@@ -55,21 +57,38 @@ describe('TagFilter', () => {
|
||||
expect(onChange).toHaveBeenCalledWith(['agent', 'rag'])
|
||||
})
|
||||
|
||||
it('clears all selected tags when the clear icon is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
it('clears selected tags through a separate keyboard control and restores focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
function Harness() {
|
||||
const [tags, setTags] = useState(['agent'])
|
||||
return <TagFilter value={tags} onChange={setTags} />
|
||||
}
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /Agent/ })
|
||||
fireEvent.click(trigger.querySelector('.i-ri-close-circle-fill')!)
|
||||
render(<Harness />)
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
const trigger = screen.getByRole('button', { name: 'Agent' })
|
||||
const clearButton = screen.getByRole('button', {
|
||||
name: /^pluginTags\.clearSelectedTags/,
|
||||
})
|
||||
expect(trigger).not.toContainElement(clearButton)
|
||||
|
||||
await user.tab()
|
||||
expect(trigger).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(clearButton).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^pluginTags\.clearSelectedTags/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.tag.tags' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('removes a selected tag when clicking the same option again', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Agent/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Agent' }))
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByText('Agent'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine, RiCloseCircleFill } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCategories } from '../../hooks'
|
||||
|
||||
@@ -23,45 +25,66 @@ const CategoriesFilter = ({ value, onChange }: CategoriesFilterProps) => {
|
||||
option.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
const selectedTagsLength = value.length
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const shouldRestoreFocusRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTagsLength || !shouldRestoreFocusRef.current) return
|
||||
|
||||
shouldRestoreFocusRef.current = false
|
||||
triggerRef.current?.focus()
|
||||
}, [selectedTagsLength])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
'data-popup-open:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex items-center p-1 system-sm-medium')}>
|
||||
{!selectedTagsLength && t(($) => $.allCategories, { ns: 'plugin' })}
|
||||
{!!selectedTagsLength &&
|
||||
value
|
||||
.map((val) => categoriesMap[val]!.label)
|
||||
.slice(0, 2)
|
||||
.join(',')}
|
||||
{selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+{selectedTagsLength - 2}
|
||||
</div>
|
||||
<div className="relative inline-flex h-8 items-center">
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className={cn(
|
||||
'h-8 rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt focus-visible:ring-inset',
|
||||
selectedTagsLength && 'pr-8 text-text-secondary',
|
||||
'data-popup-open:bg-state-base-hover',
|
||||
)}
|
||||
</div>
|
||||
{!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="size-4 cursor-pointer text-text-quaternary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!selectedTagsLength && <RiArrowDownSLine className="size-4" />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<span className="flex items-center p-1 system-sm-medium">
|
||||
{!selectedTagsLength && t(($) => $.allCategories, { ns: 'plugin' })}
|
||||
{!!selectedTagsLength &&
|
||||
value
|
||||
.map((val) => categoriesMap[val]!.label)
|
||||
.slice(0, 2)
|
||||
.join(',')}
|
||||
{selectedTagsLength > 2 && (
|
||||
<span className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+{selectedTagsLength - 2}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!selectedTagsLength && <RiArrowDownSLine aria-hidden="true" className="size-4" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!!selectedTagsLength && (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
aria-label={t(($) => $.clearSearch, {
|
||||
ns: 'plugin',
|
||||
label: t(($) => $.allCategories, { ns: 'plugin' }),
|
||||
})}
|
||||
className="absolute right-1 focus-visible:ring-inset"
|
||||
onClick={() => {
|
||||
shouldRestoreFocusRef.current = true
|
||||
onChange([])
|
||||
}}
|
||||
>
|
||||
<RiCloseCircleFill aria-hidden="true" className="size-4 text-text-quaternary" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { useTags } from '../../hooks'
|
||||
@@ -21,46 +23,65 @@ const TagsFilter = ({ value, onChange }: TagsFilterProps) => {
|
||||
option.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
const selectedTagsLength = value.length
|
||||
const selectedTagLabels = value.map((tag) => getTagLabel(tag))
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const shouldRestoreFocusRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTagsLength || !shouldRestoreFocusRef.current) return
|
||||
|
||||
shouldRestoreFocusRef.current = false
|
||||
triggerRef.current?.focus()
|
||||
}, [selectedTagsLength])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
'data-popup-open:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex items-center p-1 system-sm-medium')}>
|
||||
{!selectedTagsLength && t(($) => $['tag.tags'], { ns: 'common' })}
|
||||
{!!selectedTagsLength &&
|
||||
value
|
||||
.map((val) => getTagLabel(val))
|
||||
.slice(0, 2)
|
||||
.join(',')}
|
||||
{selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+{selectedTagsLength - 2}
|
||||
</div>
|
||||
<div className="relative inline-flex h-8 items-center">
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
className={cn(
|
||||
'h-8 rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt focus-visible:ring-inset',
|
||||
selectedTagsLength && 'pr-8 text-text-secondary',
|
||||
'data-popup-open:bg-state-base-hover',
|
||||
)}
|
||||
</div>
|
||||
{!!selectedTagsLength && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-close-circle-fill size-4 cursor-pointer text-text-quaternary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!selectedTagsLength && <span aria-hidden className="i-ri-arrow-down-s-line size-4" />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<span className="flex items-center p-1 system-sm-medium">
|
||||
{!selectedTagsLength && t(($) => $['tag.tags'], { ns: 'common' })}
|
||||
{!!selectedTagsLength && selectedTagLabels.slice(0, 2).join(',')}
|
||||
{selectedTagsLength > 2 && (
|
||||
<span className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+{selectedTagsLength - 2}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!selectedTagsLength && (
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!!selectedTagsLength && (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="md"
|
||||
aria-label={t(($) => $.clearSelectedTags, {
|
||||
ns: 'pluginTags',
|
||||
tags: selectedTagLabels.join(', '),
|
||||
})}
|
||||
className="absolute right-1 focus-visible:ring-inset"
|
||||
onClick={() => {
|
||||
shouldRestoreFocusRef.current = true
|
||||
onChange([])
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4 text-text-quaternary" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
|
||||
Reference in new issue
Block a user