diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index e41b0364e0b..0450c8208bf 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 } } -} \ No newline at end of file +} diff --git a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx index 277308ab586..61af8702376 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx @@ -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( + ( + + )} + />, + ) + + 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() diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx index 69cf18c530f..551e9f112ab 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -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 ( { 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 (
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 diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index 6cdd2164daa..d0a11801742 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -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() diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx index 13267e92264..54f979f11d4 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx @@ -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) { const language = useLanguage() + const formId = useId() const [changeKey, setChangeKey] = useState('') const filteredProps: Omit, 'override' | 'customRenderField'> = { className, @@ -196,6 +197,7 @@ function Form< const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name')) + const inputId = `${formId}-${variable}` return (
- {label[language] || label.en_US} - {required && *} + {infotipContent}
void onFocus?: () => void @@ -14,6 +15,7 @@ type InputProps = { } const Input: FC = ({ + id, value, onChange, onFocus, @@ -38,6 +40,7 @@ const Input: FC = ({ return (
{ ) 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() diff --git a/web/app/components/header/account-setting/update-setting-dialog-form.tsx b/web/app/components/header/account-setting/update-setting-dialog-form.tsx index e013a7e7964..110066febb5 100644 --- a/web/app/components/header/account-setting/update-setting-dialog-form.tsx +++ b/web/app/components/header/account-setting/update-setting-dialog-form.tsx @@ -163,6 +163,7 @@ const UpdateSettingDialogForm = ({ title={t(($) => $['autoUpdate.updateTime'], { ns: 'plugin' })} minuteFilter={minuteFilter} renderTrigger={renderTimePickerTrigger} + triggerNativeButton />
diff --git a/web/app/components/header/account-setting/update-setting-dialog.tsx b/web/app/components/header/account-setting/update-setting-dialog.tsx index d29a59688dd..9a46a5e20f3 100644 --- a/web/app/components/header/account-setting/update-setting-dialog.tsx +++ b/web/app/components/header/account-setting/update-setting-dialog.tsx @@ -209,7 +209,7 @@ const UpdateSettingDialog = ({ category, disabled = false }: Props) => { setIsOpen(false) }, [autoUpgrade, saveAutoUpgrade]) const renderTimePickerTrigger = useCallback>( - (props, state, { inputElem, onClick }: TriggerParams) => { + (props, state, { displayText, onClick }: TriggerParams) => { return ( ) }, - [timezone], + [t, timezone], ) return ( diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx index ee1bd7fc702..44c6a0f150e 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx @@ -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' })) diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx index 5091e8d4851..237044b1b0c 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-modal.spec.tsx @@ -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 () => { diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx index a2f7a13b170..3202d4a8825 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-detail.spec.tsx @@ -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', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx index b434a219202..5389fad2fb2 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/strategy-item.spec.tsx @@ -97,7 +97,10 @@ describe('Plugin agent strategies', () => { }) render() - 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 () => { diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx index cb74e31cb35..249af94f82d 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx @@ -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) => {
-
{data.name}
+
{data.name}
$['operation.edit'], { ns: 'common' })} + aria-label={`${t(($) => $['operation.edit'], { ns: 'common' })} ${data.name}`} onClick={showEndpointModalConfirm} > $['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) => { handleCopy(`${data.url}${endpoint.path}`)} > {isCopied ? ( @@ -163,6 +164,7 @@ const EndpointCard = ({ pluginDetail, data }: Props) => {
)} (null) + const validationErrorRef = React.useRef(null) const [tempCredential, setTempCredential] = React.useState>(() => { const values: Record = { ...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 = ({
-
+ {t(($) => $['detailPanel.endpointModalTitle'], { ns: 'plugin' })} -
+ $['operation.close'], { ns: 'common' })} onClick={onCancel} @@ -186,10 +192,21 @@ const EndpointModal = ({ }} >
+ {validationError && ( +

+ {validationError.message} +

+ )}
{ setTempCredential(v) + setValidationError(null) }} formSchemas={formSchemas} isEditMode={true} diff --git a/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx b/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx index 363a7fc681f..6d748aaa4c2 100644 --- a/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx +++ b/web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx @@ -46,7 +46,7 @@ const getOutputType = (schema: unknown): string => { const StrategyDetail: FC = ({ 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 = ({ provider, tenantId, detail, onHide }) => {
-
- - BACK -
+