chore: improve a11y of trigger integration, agent strategy and extension page (#43023)

This commit is contained in:
Joel authored and GitHub committed 2026-09-28 06:41:17 +00:00
1 parent 52dcf83de6
commit 2b88685fbd
29 files changed
+426 -210

No files matched your search

+1 -37
View File
@@ -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
@@ -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 || ''} `}
@@ -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}
@@ -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' })}{' '}
@@ -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()
})
@@ -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}