diff --git a/.agents/skills/frontend-code-review/references/accessibility-ui.md b/.agents/skills/frontend-code-review/references/accessibility-ui.md
index eb9cdd47728..f7bb6d6918f 100644
--- a/.agents/skills/frontend-code-review/references/accessibility-ui.md
+++ b/.agents/skills/frontend-code-review/references/accessibility-ui.md
@@ -63,7 +63,6 @@ Flag:
- Missing stable `name` on form fields that submit or validate.
- Incorrect input `type`, `inputMode`, `autoComplete`, or `spellCheck` for email, token, URL, number, search, code, or username fields.
- Labels that are not clickable.
-- Submit buttons disabled before a request starts, preventing normal submit behavior.
- Non-submit buttons inside forms missing `type="button"`.
- Errors not associated with fields or not reachable by screen readers.
- Error recovery that does not focus or expose the first invalid field.
@@ -77,7 +76,11 @@ Prefer visible labels and associate them through the appropriate field primitive
Flag:
-- Loading state without `aria-busy`, `role="status"`, or another accessible update path when it changes user interaction.
+- Loading controls whose accessible name disappears, or whose user-relevant progress has no
+ feature-owned status path. Follow the Dify UI Button contract for focused loading buttons; do not
+ add `aria-busy` to a button as a generic pending-state substitute.
+- The same pending state passed to both Dify UI Button `loading` and `disabled`, which duplicates
+ state ownership and obscures whether `disabled` expresses independent unavailability.
- Spinner or decorative loading icon exposed to screen readers.
- Disabled controls that hide the reason users cannot proceed.
- `aria-disabled` used without manually blocking click, Space, and Enter.
diff --git a/packages/dify-ui/docs/accessible-names-and-descriptions.md b/packages/dify-ui/docs/accessible-names-and-descriptions.md
index d948c3290ff..71c3cbaafa6 100644
--- a/packages/dify-ui/docs/accessible-names-and-descriptions.md
+++ b/packages/dify-ui/docs/accessible-names-and-descriptions.md
@@ -38,7 +38,7 @@ text is not a label relationship by proximity alone.
| Surface | Contract |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Text button or link | Follow [Button]. Let meaningful child text name the action; do not repeat it in `aria-label`. |
+| Text button or link | Let meaningful child text name the action; do not repeat it in `aria-label`. Follow [Button] when a focused loading button changes its label. |
| Form control | Follow [Forms]. Use its label primitive or an associated native `label`, preserving label activation. |
| Icon-only command | Follow [IconButton]. Its component-specific contract requires one accessible-name source and a decorative glyph. |
| Dialog or named region | Reuse the visible title through the primitive title API or `aria-labelledby`; use `aria-label` only when no suitable visible title exists. |
@@ -58,9 +58,9 @@ A description is optional when the name is sufficient. For a file action, the na
the operation and file, while the description explains retention or recovery. Avoid repeating the
same sentence in both. See the [name and description computation specification][accname].
-A name or description attribute is not an announcement mechanism. Keep progress and asynchronous
-updates with their existing feature owner. Follow [Button] for loading behavior and [Forms] for
-field error relationships.
+A name or description attribute is not a general announcement mechanism. Keep progress and
+asynchronous updates with their existing feature owner. Follow [Button] for the narrower case of a
+focused loading button whose visible label changes, and [Forms] for field error relationships.
## Overrides and References
diff --git a/packages/dify-ui/src/alert-dialog/index.stories.tsx b/packages/dify-ui/src/alert-dialog/index.stories.tsx
index bc2cf398148..d0a8fa7efe4 100644
--- a/packages/dify-ui/src/alert-dialog/index.stories.tsx
+++ b/packages/dify-ui/src/alert-dialog/index.stories.tsx
@@ -152,6 +152,7 @@ export const Controlled: Story = {
const LoadingConfirmDemo = () => {
const [pending, setPending] = React.useState(false)
const [open, setOpen] = React.useState(false)
+ const confirmLabelId = React.useId()
const handleConfirm = () => {
setPending(true)
@@ -180,8 +181,13 @@ const LoadingConfirmDemo = () => {
Cancel
-
- {pending ? 'Archiving…' : 'Archive'}
+
+ {pending ? 'Archiving…' : 'Archive'}
@@ -191,4 +197,16 @@ const LoadingConfirmDemo = () => {
export const LoadingConfirm: Story = {
render: () => ,
+ play: async ({ canvas, canvasElement, userEvent }) => {
+ const body = within(canvasElement.ownerDocument.body)
+
+ await userEvent.click(canvas.getByRole('button', { name: 'Archive workspace' }))
+ const confirmButton = body.getByRole('button', { name: 'Archive' })
+ await userEvent.click(confirmButton)
+
+ await expect(confirmButton).toHaveAccessibleName('Archiving…')
+ await expect(confirmButton).toHaveAttribute('aria-disabled', 'true')
+ await expect(confirmButton).not.toHaveAttribute('aria-busy')
+ await expect(confirmButton).toHaveFocus()
+ },
}
diff --git a/packages/dify-ui/src/button/README.md b/packages/dify-ui/src/button/README.md
index 8ece6c6bdc0..b8a1e41f43b 100644
--- a/packages/dify-ui/src/button/README.md
+++ b/packages/dify-ui/src/button/README.md
@@ -41,19 +41,9 @@ button semantics. It is not a link mode.
| `disabled` | The action is unavailable. | Native-disabled and removed from the tab order. |
| `loading` | The action was triggered and is now pending. | Activation is blocked while the button retains focus. |
-Internally, Dify UI maps these states to Base UI's interaction contract:
-
-```tsx
-disabled={disabled || loading}
-focusableWhenDisabled={focusableWhenDisabled ?? loading}
-```
-
-Base UI recommends disabling a loading button while setting `focusableWhenDisabled` so that an
-action does not lose focus after it is triggered. Dify UI's `loading` prop owns that wiring and
-adds the visible spinner. The loading button remains in the tab order with `aria-disabled`
-instead of the native [`disabled`] attribute. Unlike native disabled, [`aria-disabled`] preserves
-focusability but requires the component to suppress activation. Callers should pass the pending
-state only to `loading`:
+`loading` owns Base UI's disabled interaction, retained focus, and the decorative spinner. The
+button remains in the tab order with `aria-disabled`, and Dify UI suppresses activation. Pass the
+pending state only to `loading`:
```tsx
Save
@@ -62,44 +52,55 @@ state only to `loading`:
Keep independent availability conditions in `disabled`:
```tsx
-
+
Save
```
-Do not repeat the same pending state in `disabled`:
+Do not repeat the pending state in `disabled`:
```tsx
-// Incorrect: loading already blocks activation.
-
- Save
-
-
-// Incorrect: keep only the independent availability condition in disabled.
-
+// Incorrect: loading already handles isSaving.
+
Save
// Correct.
-
+
Save
```
-It is valid for `loading` and an independent `disabled` condition to both evaluate to `true`.
-The loading focus policy applies while the action is pending; when loading ends, the remaining
-availability condition still determines whether the button is disabled.
-
Pass `focusableWhenDisabled={false}` only when a loading button should opt into native disabled
behavior and may leave the tab order.
### Accessible loading feedback
-The loading spinner is decorative and does not replace the button's visible label. `Button` does
-not add `aria-busy`: [WAI-ARIA `aria-busy`] defines it for an element being modified whose
-content changes may be deferred by assistive technology, not as a generic substitute for a
-pending action state. When a long-running operation needs an announcement or progress updates,
-the feature owns the corresponding status, live region, or progress component.
+The spinner is decorative. Keep a non-empty visible label throughout loading. If the visible label
+stays the same, its text continues to name the button:
+
+```tsx
+Save
+```
+
+When the label changes while the focused button enters loading, give the changing text a stable ID
+and reference it explicitly. Some browser and screen-reader combinations do not reliably announce
+changes to a focused button's descendant text:
+
+```tsx
+const labelId = useId()
+
+
+ {isSaving ? 'Saving' : 'Save'}
+
+```
+
+The consumer owns this relationship because only it knows whether the label changes and whether
+other visible context must also be referenced. Do not replace the changing text with `aria-label`.
+
+`Button` does not add `aria-busy`: [WAI-ARIA `aria-busy`] describes an element whose own updates may
+be deferred by assistive technology, not a generic pending action. Long-running announcements and
+progress remain with the feature's status, live-region, or progress owner.
## Content and spacing
@@ -126,5 +127,3 @@ for the other variants. Use a `className` override only for a documented layout
[Base UI Button]: https://base-ui.com/react/components/button
[WAI-ARIA `aria-busy`]: https://www.w3.org/TR/wai-aria#aria-busy
[`IconButton`]: ../icon-button/README.md
-[`aria-disabled`]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-disabled
-[`disabled`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/disabled
diff --git a/packages/dify-ui/src/button/index.stories.tsx b/packages/dify-ui/src/button/index.stories.tsx
index 793d7bffd50..4bdb6d3c24b 100644
--- a/packages/dify-ui/src/button/index.stories.tsx
+++ b/packages/dify-ui/src/button/index.stories.tsx
@@ -1,7 +1,30 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
+import type { ButtonProps } from '.'
+import * as React from 'react'
import { expect, fn } from 'storybook/test'
import { Button, buttonVariants } from '.'
+type LoadingButtonExampleProps = Pick
+
+function LoadingButtonExample({ onClick, variant }: LoadingButtonExampleProps) {
+ const [loading, setLoading] = React.useState(false)
+ const labelId = React.useId()
+
+ return (
+ {
+ onClick?.(event)
+ setLoading(true)
+ }}
+ variant={variant}
+ >
+ {loading ? 'Saving' : 'Save'}
+
+ )
+}
+
const meta = {
title: 'Base/UI/Button',
component: Button,
@@ -88,27 +111,30 @@ export const Disabled: Story = {
export const Loading: Story = {
args: {
variant: 'primary',
- loading: true,
onClick: fn(),
- children: 'Loading Button',
},
+ render: ({ onClick, variant }) => ,
play: async ({ args, canvas, userEvent }) => {
- const button = canvas.getByRole('button', { name: 'Loading Button' })
-
- await expect(button).toHaveAttribute('aria-disabled', 'true')
- await expect(button).not.toHaveAttribute('aria-busy')
+ const button = canvas.getByRole('button', { name: 'Save' })
button.focus()
await expect(button).toHaveFocus()
await userEvent.click(button)
- await expect(args.onClick).not.toHaveBeenCalled()
+ await expect(args.onClick).toHaveBeenCalledTimes(1)
+ await expect(button).toHaveAccessibleName('Saving')
+ await expect(button).toHaveAttribute('aria-disabled', 'true')
+ await expect(button).not.toHaveAttribute('aria-busy')
+ await expect(button).toHaveFocus()
+
+ await userEvent.keyboard('{Enter}')
+ await expect(args.onClick).toHaveBeenCalledTimes(1)
},
parameters: {
docs: {
description: {
story:
- 'Loading buttons remain focusable by default so focus is not lost after activation. Pass `focusableWhenDisabled={false}` to opt out.',
+ 'When a focused button changes its visible label during loading, give that label a stable ID and reference it with `aria-labelledby`. Loading blocks repeated activation while retaining focus.',
},
},
},
diff --git a/packages/dify-ui/src/button/index.tsx b/packages/dify-ui/src/button/index.tsx
index d6cb0b7f1d9..629b1032d89 100644
--- a/packages/dify-ui/src/button/index.tsx
+++ b/packages/dify-ui/src/button/index.tsx
@@ -105,6 +105,11 @@ const buttonVariants = cva(
type ButtonProps = Omit &
VariantProps & {
+ /**
+ * Marks an action as pending, blocks activation, and keeps the button focusable by default.
+ * Keep a non-empty visible label. If that label changes while loading, give it a stable ID and
+ * reference it with `aria-labelledby` so it remains the explicit accessible name.
+ */
loading?: boolean
className?: string
}
diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx
index 49a16e6f9d4..1f4ead65353 100644
--- a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx
+++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx
@@ -94,13 +94,7 @@ export default function CheckCode() {
className="mt-1"
placeholder={t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) || ''}
/>
-
+
{t(($) => $['checkCode.verify'], { ns: 'login' })}
diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx
index fb2c00b1a71..9c0fd0e2b34 100644
--- a/web/app/(shareLayout)/webapp-reset-password/page.tsx
+++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx
@@ -90,7 +90,6 @@ export default function CheckCode() {
$['checkCode.verificationCodePlaceholder'], { ns: 'login' }) || ''}
/>
-
+
{t(($) => $['checkCode.verify'], { ns: 'login' })}
diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx
index 421e1a31c11..3906b57fe63 100644
--- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx
+++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx
@@ -89,7 +89,7 @@ export default function MailAndCodeAuth() {
diff --git a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx
index c69842eb931..bab0d50d9e4 100644
--- a/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx
+++ b/web/app/account/(commonLayout)/account-page/AvatarWithEdit.tsx
@@ -157,7 +157,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx
index a8924817e71..215d9d2f5b5 100644
--- a/web/app/account/oauth/authorize/page.tsx
+++ b/web/app/account/oauth/authorize/page.tsx
@@ -246,7 +246,7 @@ export default function OAuthAuthorize() {
size="large"
className="w-full"
onClick={onAuthorize}
- disabled={!clientId || !redirectUri || isOAuthError || authorizing}
+ disabled={!clientId || !redirectUri || isOAuthError}
loading={authorizing}
>
{t(($) => $.continue, { ns: 'oauth' })}
diff --git a/web/app/components/access-rules-editor/add-access-subject-popover.tsx b/web/app/components/access-rules-editor/add-access-subject-popover.tsx
index d754762c592..b024cf7eb1e 100644
--- a/web/app/components/access-rules-editor/add-access-subject-popover.tsx
+++ b/web/app/components/access-rules-editor/add-access-subject-popover.tsx
@@ -187,7 +187,7 @@ function AddAccessSubjectPopover({
diff --git a/web/app/components/app-sidebar/app-info/app-info-modals.tsx b/web/app/components/app-sidebar/app-info/app-info-modals.tsx
index 24678f42aff..f54e16fca11 100644
--- a/web/app/components/app-sidebar/app-info/app-info-modals.tsx
+++ b/web/app/components/app-sidebar/app-info/app-info-modals.tsx
@@ -66,6 +66,7 @@ const AppInfoModals = ({
const { t } = useTranslation()
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const [isSecretExporting, setIsSecretExporting] = useState(false)
+ const exportConfirmLabelId = React.useId()
const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name
const exportDialogMode =
secretEnvList.length > 0 ? 'secret' : activeModal === 'exportWarning' ? 'warning' : null
@@ -223,11 +224,14 @@ const AppInfoModals = ({
- {isExporting
- ? t(($) => $['operation.exporting'], { ns: 'common' })
- : t(($) => $['operation.confirm'], { ns: 'common' })}
+
+ {isExporting
+ ? t(($) => $['operation.exporting'], { ns: 'common' })
+ : t(($) => $['operation.confirm'], { ns: 'common' })}
+
diff --git a/web/app/components/app/annotation/batch-action.tsx b/web/app/components/app/annotation/batch-action.tsx
index e8f013e4d46..ef21af44d38 100644
--- a/web/app/components/app/annotation/batch-action.tsx
+++ b/web/app/components/app/annotation/batch-action.tsx
@@ -73,11 +73,7 @@ const BatchAction: FC = ({
{t(($) => $['operation.cancel'], { ns: 'common' })}
-
+
{t(($) => $['operation.delete'], { ns: 'common' })}
diff --git a/web/app/components/app/app-access-control/access-control-form.tsx b/web/app/components/app/app-access-control/access-control-form.tsx
index 182172516ea..62dd36cfe9b 100644
--- a/web/app/components/app/app-access-control/access-control-form.tsx
+++ b/web/app/components/app/app-access-control/access-control-form.tsx
@@ -46,7 +46,6 @@ export function AccessControlForm({
const accessControlOptionsLabelId = useId()
const { t } = useTranslation()
const confirmDisabled =
- updatePending ||
(accessMode === AccessModeValue.PUBLIC && publicAccessDisabled) ||
(accessMode === AccessModeValue.SPECIFIC_GROUPS_MEMBERS && subjectsStatus !== 'success')
diff --git a/web/app/components/app/app-publisher/version-info-modal.tsx b/web/app/components/app/app-publisher/version-info-modal.tsx
index 2a5deb45ed9..01120b0a44d 100644
--- a/web/app/components/app/app-publisher/version-info-modal.tsx
+++ b/web/app/components/app/app-publisher/version-info-modal.tsx
@@ -113,10 +113,8 @@ const VersionInfoModal: FC = ({
-
- {t(($) => $['operation.cancel'], { ns: 'common' })}
-
-
+ {t(($) => $['operation.cancel'], { ns: 'common' })}
+
{t(($) => $['operation.save'], { ns: 'common' })}
diff --git a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx
index 7e1b8d9c92d..7c9c17e6cf9 100644
--- a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx
+++ b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx
@@ -73,7 +73,6 @@ export function EnvironmentDeployMenu({
size="small"
variant="ghost"
loading={isRetrying}
- disabled={isRetrying}
className="gap-1 px-2"
onClick={() => void refetchEnvironments()}
>
diff --git a/web/app/components/app/deploy/environment-table/index.tsx b/web/app/components/app/deploy/environment-table/index.tsx
index 3711f311c64..28230e6b857 100644
--- a/web/app/components/app/deploy/environment-table/index.tsx
+++ b/web/app/components/app/deploy/environment-table/index.tsx
@@ -151,7 +151,6 @@ export const EnvironmentTable = memo(
size="small"
variant="secondary"
loading={latestVersionIsRetrying}
- disabled={latestVersionIsRetrying}
onClick={() => void refetchLatestVersion()}
className="gap-1 px-2"
>
diff --git a/web/app/components/app/deploy/environment-table/undeploy-confirm-dialog/index.tsx b/web/app/components/app/deploy/environment-table/undeploy-confirm-dialog/index.tsx
index b68a3adca31..b7e6d184a02 100644
--- a/web/app/components/app/deploy/environment-table/undeploy-confirm-dialog/index.tsx
+++ b/web/app/components/app/deploy/environment-table/undeploy-confirm-dialog/index.tsx
@@ -56,12 +56,7 @@ export function UndeployConfirmDialog({
{tCommon(($) => $['operation.cancel'])}
-
+
{t(($) => $['deployTab.confirmUndeploy'])}
diff --git a/web/app/components/apps/app-card/interactions.tsx b/web/app/components/apps/app-card/interactions.tsx
index 0bdb03c2101..0521f0b0103 100644
--- a/web/app/components/apps/app-card/interactions.tsx
+++ b/web/app/components/apps/app-card/interactions.tsx
@@ -357,7 +357,8 @@ export function AppCardInteractions({
[isDeleting],
)
- const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name
+ const deleteNameMismatch = confirmDeleteInput !== app.name
+ const isDeleteConfirmDisabled = isDeleting || deleteNameMismatch
const onDeleteDialogSubmit: FormEventHandler = useCallback(
(e) => {
@@ -737,7 +738,7 @@ export function AppCardInteractions({
{t(($) => $['operation.confirm'], { ns: 'common' })}
diff --git a/web/app/components/apps/import-from-marketplace-template-modal.tsx b/web/app/components/apps/import-from-marketplace-template-modal.tsx
index c28a24b8230..d92dfdc1053 100644
--- a/web/app/components/apps/import-from-marketplace-template-modal.tsx
+++ b/web/app/components/apps/import-from-marketplace-template-modal.tsx
@@ -171,7 +171,7 @@ const ImportFromMarketplaceTemplateModal = ({
diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx
index 67ff538474d..45d58c0cc60 100644
--- a/web/app/components/base/app-icon-picker/index.tsx
+++ b/web/app/components/base/app-icon-picker/index.tsx
@@ -231,13 +231,7 @@ function AppIconPickerContent({
{t(($) => $['iconPicker.cancel'], { ns: 'app' })}
-
+
{t(($) => $['iconPicker.ok'], { ns: 'app' })}
diff --git a/web/app/components/base/form/components/form/__tests__/actions.spec.tsx b/web/app/components/base/form/components/form/__tests__/actions.spec.tsx
index 2141bc4b050..2ca526c0029 100644
--- a/web/app/components/base/form/components/form/__tests__/actions.spec.tsx
+++ b/web/app/components/base/form/components/form/__tests__/actions.spec.tsx
@@ -60,6 +60,14 @@ describe('Actions', () => {
expect(screen.getByRole('button', { name: 'common.operation.submit' })).toBeDisabled()
})
+ it('should keep the pending submit button focusable when canSubmit includes submitting state', () => {
+ renderWithForm({ canSubmit: false, isSubmitting: true })
+ const submitButton = screen.getByRole('button', { name: 'common.operation.submit' })
+
+ expect(submitButton).not.toBeDisabled()
+ expect(submitButton).toHaveAttribute('aria-disabled', 'true')
+ })
+
it('should call form submit when users click submit button', async () => {
const submitSpy = vi.fn()
renderWithForm({ onSubmit: submitSpy })
diff --git a/web/app/components/base/form/components/form/actions.tsx b/web/app/components/base/form/components/form/actions.tsx
index 7462fb067b4..646f0baf7c6 100644
--- a/web/app/components/base/form/components/form/actions.tsx
+++ b/web/app/components/base/form/components/form/actions.tsx
@@ -28,7 +28,7 @@ const Actions = ({ CustomActions }: ActionsProps) => {
return (
form.handleSubmit()}
>
diff --git a/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx
index 42e4dadad4e..bfa553b555e 100644
--- a/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx
+++ b/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx
@@ -34,11 +34,10 @@ describe('UrlInput', () => {
expect(button).toHaveTextContent(/run/i)
})
- it('should render button without run text when running', () => {
+ it('should keep the run label while running', () => {
render( )
- const button = screen.getByRole('button')
- // Button should not have "run" text when running (shows loading state instead)
- expect(button).not.toHaveTextContent(/run/i)
+ const button = screen.getByRole('button', { name: /run/i })
+ expect(button).toHaveTextContent(/run/i)
})
it('should show loading state on button when running', () => {
@@ -139,16 +138,14 @@ describe('UrlInput', () => {
rerender( )
- // When running, button shows loading state instead of "run" text
- expect(button).not.toHaveTextContent(/run/i)
+ expect(button).toHaveAccessibleName(/run/i)
})
it('should update button state when isRunning changes from true to false', () => {
const { rerender } = render( )
- const button = screen.getByRole('button')
- // When running, button shows loading state instead of "run" text
- expect(button).not.toHaveTextContent(/run/i)
+ const button = screen.getByRole('button', { name: /run/i })
+ expect(button).toHaveTextContent(/run/i)
rerender( )
@@ -283,25 +280,6 @@ describe('UrlInput', () => {
})
})
- // Button Text Branch Coverage Tests
- describe('Button Text Branch Coverage', () => {
- it('should display run text when isRunning is false (branch: !isRunning = true)', () => {
- render( )
-
- const button = screen.getByRole('button')
- // When !isRunning is true, button shows the translated "run" text
- expect(button).toHaveTextContent(/run/i)
- })
-
- it('should not display run text when isRunning is true (branch: !isRunning = false)', () => {
- render( )
-
- const button = screen.getByRole('button')
- // When !isRunning is false, button shows empty string '' (loading state shows spinner)
- expect(button).not.toHaveTextContent(/run/i)
- })
- })
-
describe('Memoization', () => {
it('should use useCallback for handleUrlChange', async () => {
const user = userEvent.setup()
@@ -356,7 +334,7 @@ describe('UrlInput', () => {
// Simulate running state
rerender( )
- expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
+ expect(screen.getByRole('button')).toHaveAccessibleName(/run/i)
// Simulate finished state
rerender( )
diff --git a/web/app/components/datasets/create/website/base/url-input.tsx b/web/app/components/datasets/create/website/base/url-input.tsx
index a3ba51c4049..860d8b26b06 100644
--- a/web/app/components/datasets/create/website/base/url-input.tsx
+++ b/web/app/components/datasets/create/website/base/url-input.tsx
@@ -30,7 +30,7 @@ const UrlInput: FC = ({ isRunning, onRun }) => {
- {!isRunning ? t(($) => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' }) : ''}
+ {t(($) => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' })}
)
diff --git a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx
index 3dbc7bc1257..260e546842a 100644
--- a/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx
@@ -414,9 +414,10 @@ describe('FireCrawl', () => {
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
- // Button should show loading state (no longer show "run" text)
await waitFor(() => {
- expect(runButton).not.toHaveTextContent(/run/i)
+ expect(runButton).toHaveAccessibleName(/run/i)
+ expect(runButton).toHaveAttribute('aria-disabled', 'true')
+ expect(runButton).toHaveFocus()
})
await act(async () => {
diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx
index 641a0264c6f..4cc13a828fc 100644
--- a/web/app/components/datasets/documents/components/operations.tsx
+++ b/web/app/components/datasets/documents/components/operations.tsx
@@ -550,11 +550,7 @@ const Operations = ({
{t(($) => $['operation.cancel'], { ns: 'common' })}
- onOperate('delete')}
- >
+ onOperate('delete')}>
{t(($) => $['operation.sure'], { ns: 'common' })}
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx
index 0a7eaab0687..7c44569960b 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx
@@ -142,6 +142,8 @@ const createDefaultProps = (overrides?: Partial): OptionsProps =>
...overrides,
})
+const getRunButton = () => screen.getByRole('button', { name: /run/i })
+
describe('Options', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -169,7 +171,7 @@ describe('Options', () => {
render( )
- expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(getRunButton()).toBeInTheDocument()
expect(screen.getByText(/run/i)).toBeInTheDocument()
})
@@ -260,7 +262,7 @@ describe('Options', () => {
render( )
- expect(screen.getByText(/running/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /running/i })).toBeInTheDocument()
})
it('should keep button loading-disabled when step is running', () => {
@@ -268,7 +270,7 @@ describe('Options', () => {
render( )
- expectLoadingButton(screen.getByRole('button'))
+ expectLoadingButton(getRunButton())
})
it('should enable button when step is finished', () => {
@@ -276,7 +278,7 @@ describe('Options', () => {
render( )
- expect(screen.getByRole('button')).not.toBeDisabled()
+ expect(getRunButton()).not.toBeDisabled()
})
})
@@ -286,7 +288,7 @@ describe('Options', () => {
render( )
- expect(screen.getByRole('button')).toBeDisabled()
+ expect(getRunButton()).toBeDisabled()
})
it('should enable button when runDisabled is false and step is not running', () => {
@@ -294,7 +296,7 @@ describe('Options', () => {
render( )
- expect(screen.getByRole('button')).not.toBeDisabled()
+ expect(getRunButton()).not.toBeDisabled()
})
it('should disable button when both runDisabled is true and step is running', () => {
@@ -302,7 +304,7 @@ describe('Options', () => {
render( )
- expectLoadingButton(screen.getByRole('button'))
+ expectLoadingButton(getRunButton())
})
it('should default runDisabled to undefined (falsy)', () => {
@@ -311,7 +313,7 @@ describe('Options', () => {
render( )
- expect(screen.getByRole('button')).not.toBeDisabled()
+ expect(getRunButton()).not.toBeDisabled()
})
})
@@ -328,7 +330,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
expect(mockOnSubmit).toHaveBeenCalled()
})
@@ -347,7 +349,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
expect(mockOnSubmit).not.toHaveBeenCalled()
})
@@ -373,7 +375,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
expect(mockOnSubmit).toHaveBeenCalledWith({ url: 'https://example.com', depth: 2 })
})
@@ -489,7 +491,7 @@ describe('Options', () => {
render( )
// Assert - Button should not be in loading state
- const button = screen.getByRole('button')
+ const button = getRunButton()
expect(button).not.toBeDisabled()
expect(screen.getByText(/run/i)).toBeInTheDocument()
})
@@ -499,7 +501,7 @@ describe('Options', () => {
render( )
- const button = screen.getByRole('button')
+ const button = getRunButton()
expectLoadingButton(button)
expect(screen.getByText(/running/i)).toBeInTheDocument()
})
@@ -525,7 +527,7 @@ describe('Options', () => {
render( )
// Act - Trigger validation via submit
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
// Assert - onSubmit should be called if validation passes
expect(mockOnSubmit).toHaveBeenCalled()
@@ -583,7 +585,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
expect(mockOnSubmit).toHaveBeenCalled()
})
@@ -594,7 +596,7 @@ describe('Options', () => {
render( )
// Act - Try to click disabled button
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
expect(mockOnSubmit).not.toHaveBeenCalled()
})
@@ -607,7 +609,7 @@ describe('Options', () => {
expect(screen.getByTestId('field-test_variable')).toBeInTheDocument()
// Act - Submit form
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
// Assert - Should still be expanded (unless step changes)
expect(screen.getByTestId('field-test_variable')).toBeInTheDocument()
@@ -643,7 +645,7 @@ describe('Options', () => {
const props = createDefaultProps()
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
// Assert - Toast should be called with error message
expect(mockToastError).toHaveBeenCalled()
@@ -661,7 +663,7 @@ describe('Options', () => {
const props = createDefaultProps()
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
// Assert - Toast message should contain field path
expect(mockToastError).toHaveBeenCalledWith(expect.stringContaining('email_address'))
@@ -675,7 +677,7 @@ describe('Options', () => {
// Assert - Should render without errors
expect(container.querySelector('form')).toBeInTheDocument()
- expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(getRunButton()).toBeInTheDocument()
})
it('should handle single variable configuration', () => {
@@ -721,7 +723,7 @@ describe('Options', () => {
const props = createDefaultProps()
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
// Assert - Toast should be called once (only first error)
expect(mockToastError).toHaveBeenCalledTimes(1)
@@ -741,7 +743,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
// Assert - No toast error, onSubmit called
expect(mockToastError).not.toHaveBeenCalled()
@@ -786,7 +788,7 @@ describe('Options', () => {
render( )
- const button = screen.getByRole('button')
+ const button = getRunButton()
if (propVariation.step === CrawlStep.running) expectLoadingButton(button)
else if (expectedDisabled) expect(button).toBeDisabled()
else expect(button).not.toBeDisabled()
@@ -839,7 +841,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
expect(mockOnSubmit).toHaveBeenCalled()
expect(mockToastError).not.toHaveBeenCalled()
@@ -858,7 +860,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
expect(mockOnSubmit).not.toHaveBeenCalled()
expect(mockToastError).toHaveBeenCalled()
@@ -876,7 +878,7 @@ describe('Options', () => {
const props = createDefaultProps()
render( )
- fireEvent.click(screen.getByRole('button'))
+ fireEvent.click(getRunButton())
expect(mockToastError).toHaveBeenCalledWith(expect.any(String))
})
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx
index fdc6000b828..2866e4f2533 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx
@@ -4,7 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiPlayLargeLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
-import { useEffect, useMemo } from 'react'
+import { useEffect, useId, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppForm } from '@/app/components/base/form'
import BaseField from '@/app/components/base/form/form-scenarios/base/field'
@@ -27,6 +27,7 @@ type OptionsProps = {
const Options = ({ variables, step, runDisabled, onSubmit }: OptionsProps) => {
const { t } = useTranslation()
+ const runButtonLabelId = useId()
const initialData = useInitialData(variables)
const configurations = useConfigurations(variables)
const schema = useMemo(() => {
@@ -61,7 +62,7 @@ const Options = ({ variables, step, runDisabled, onSubmit }: OptionsProps) => {
else foldShow()
}, [step])
- const isRunning = useMemo(() => step === CrawlStep.running, [step])
+ const isRunning = step === CrawlStep.running
return (
diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx
index c5a5a39cbfd..b36c4443de8 100644
--- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx
+++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx
@@ -167,13 +167,7 @@ function InviteForm({ isEmailSetup, onOpenChange, onSend }: InviteFormProps) {
{submissionError.message}
)}
-
+
{validRecipientCount > 0
? t(($) => $['members.sendInviteCount'], {
ns: 'common',
diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/config-model.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/config-model.spec.tsx
index 27d616ac134..c76e937938d 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/config-model.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/config-model.spec.tsx
@@ -12,11 +12,11 @@ describe('ConfigModel', () => {
name: 'common.modelProvider.auth.authorizationError',
props: { loadBalancingInvalid: true },
},
- ])('announces loading for the $name action', ({ name, props }) => {
+ ])('keeps the $name action focusable and unavailable while loading', ({ name, props }) => {
render( )
const action = screen.getByRole('button', { name })
- expect(action).toHaveAttribute('aria-busy', 'true')
expect(action).toHaveAttribute('aria-disabled', 'true')
+ expect(action).not.toHaveAttribute('aria-busy')
})
})
diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx
index 14c32514537..cf84cd75988 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx
@@ -1,7 +1,6 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
-import { RiEqualizer2Line, RiScales3Line } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -30,11 +29,10 @@ const ConfigModel = ({
size="small"
loading={loading}
disabled={disabled}
- aria-busy={loading || undefined}
className="relative h-4.5 rounded-[5px] border border-text-warning bg-components-badge-bg-dimm px-1.5 system-2xs-medium-uppercase text-text-warning shadow-none hover:bg-components-badge-bg-dimm"
onClick={onClick}
>
-
+
{t(($) => $['modelProvider.auth.authorizationError'], { ns: 'common' })}
@@ -47,7 +45,6 @@ const ConfigModel = ({
size="small"
loading={loading}
disabled={disabled}
- aria-busy={loading || undefined}
className={cn('hidden shrink-0 group-hover:flex', credentialRemoved && 'flex')}
onClick={onClick}
>
@@ -59,13 +56,13 @@ const ConfigModel = ({
)}
{!loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && (
<>
-
+
{t(($) => $['operation.config'], { ns: 'common' })}
>
)}
{loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && (
<>
-
+
{t(($) => $['modelProvider.auth.configLoadBalancing'], { ns: 'common' })}
>
)}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/marketplace-section.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/marketplace-section.spec.tsx
new file mode 100644
index 00000000000..86bf60e5cde
--- /dev/null
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/marketplace-section.spec.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '@testing-library/react'
+import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
+import MarketplaceSection from '../marketplace-section'
+
+const defaultProps = {
+ marketplaceProviders: [ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.ANTHROPIC],
+ marketplaceCollapsed: false,
+ installingProvider: null,
+ canInstallPlugin: true,
+ onMarketplaceCollapsedChange: vi.fn(),
+ onInstallPlugin: vi.fn(),
+}
+
+describe('MarketplaceSection', () => {
+ it('keeps the install action named and focusable while it is pending', () => {
+ const { rerender } = render( )
+ const installButton = screen.getByRole('button', {
+ name: 'common.modelProvider.selector.install OpenAI',
+ })
+ expect(
+ screen.getByRole('button', {
+ name: 'common.modelProvider.selector.install Anthropic',
+ }),
+ ).toBeInTheDocument()
+ installButton.focus()
+
+ rerender(
+ ,
+ )
+
+ expect(installButton).toHaveAccessibleName('plugin.installModal.installing OpenAI')
+ expect(installButton).toHaveAttribute('aria-disabled', 'true')
+ expect(installButton).not.toHaveAttribute('aria-busy')
+ expect(installButton).toHaveFocus()
+ })
+})
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx
index b036c47b0d4..3b3c6491fd9 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx
@@ -63,6 +63,8 @@ function MarketplaceSection({
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
+ const installButtonLabelId = `${headingId}-install-${key}`
+ const providerNameId = `${headingId}-provider-${key}`
return (
-
+
{modelNameMap[key]}
@@ -78,24 +80,20 @@ function MarketplaceSection({
onInstallPlugin(key)}
>
- {isInstalling && (
-
- )}
- {isInstalling
- ? t(($) => $['installModal.installing'], { ns: 'plugin' })
- : t(($) => $['modelProvider.selector.install'], { ns: 'common' })}
+
+ {isInstalling
+ ? t(($) => $['installModal.installing'], { ns: 'plugin' })
+ : t(($) => $['modelProvider.selector.install'], { ns: 'common' })}
+
)}
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx
index 45f9bef18f4..f2b2d1e901a 100644
--- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/provider-card-actions.tsx
@@ -269,7 +269,7 @@ function SummaryProviderCardActions({ summary, providerLabel, onUpdate }: Summar
{t(($) => $['operation.cancel'], { ns: 'common' })}
-
+
{t(($) => $['operation.confirm'], { ns: 'common' })}
diff --git a/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx b/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx
index ee06477a2a5..46380de922a 100644
--- a/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx
+++ b/web/app/components/header/account-setting/permissions-page/role-list/copy-members-confirm-dialog.tsx
@@ -62,7 +62,7 @@ export function CopyMembersConfirmDialog({
onDuplicate(true)}
>
diff --git a/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx
index 02b6b434883..fabfdb749bf 100644
--- a/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/workflow-log-archives-page/__tests__/index.spec.tsx
@@ -126,6 +126,11 @@ describe('WorkflowLogArchivesPage', () => {
expect(screen.queryByText('appLog.archives.upgradeTip.title')).not.toBeInTheDocument()
expect(screen.getByText('2025-03')).toBeInTheDocument()
expect(screen.getAllByText('125').length).toBeGreaterThan(0)
+ expect(
+ screen.getByRole('button', {
+ name: 'appLog.archives.action.prepareDownload 2025-03',
+ }),
+ ).toBeInTheDocument()
})
})
})
diff --git a/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx b/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx
index a2fcc882ae5..decf2427795 100644
--- a/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx
+++ b/web/app/components/header/account-setting/workflow-log-archives-page/index.tsx
@@ -10,7 +10,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { skipToken, useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { API_PREFIX } from '@/config'
@@ -285,6 +285,8 @@ function ArchivedLogsUpgradeBanner() {
function WorkflowArchiveMonthRow({ archive }: { archive: WorkflowRunArchiveMonthResponse }) {
const { t } = useTranslation()
+ const archiveMonthLabelId = useId()
+ const downloadActionLabelId = useId()
const [downloadTask, setDownloadTask] = useState(
null,
)
@@ -367,9 +369,6 @@ function WorkflowArchiveMonthRow({ archive }: { archive: WorkflowRunArchiveMonth
return t(($) => $['archives.action.prepareDownload'], { ns: 'appLog' })
})()
- const buttonAriaLabel = isReady
- ? t(($) => $['archives.action.downloadMonth'], { ns: 'appLog', month: archiveMonth })
- : t(($) => $['archives.action.prepareMonth'], { ns: 'appLog', month: archiveMonth })
const buttonIconClassName = isReady ? 'i-ri-download-2-line' : 'i-ri-inbox-archive-line'
const onAction = isReady ? downloadArchive : prepareDownload
@@ -381,7 +380,9 @@ function WorkflowArchiveMonthRow({ archive }: { archive: WorkflowRunArchiveMonth
)}
>
- {archiveMonth}
+
+ {archiveMonth}
+
{formatNumber(archive.workflow_run_count)}
@@ -397,15 +398,14 @@ function WorkflowArchiveMonthRow({ archive }: { archive: WorkflowRunArchiveMonth
size="small"
variant="secondary"
loading={isPreparing}
- disabled={isPreparing}
className="px-2"
- aria-label={buttonAriaLabel}
+ aria-labelledby={`${downloadActionLabelId} ${archiveMonthLabelId}`}
onClick={onAction}
>
{!isPreparing && (
)}
- {buttonContent}
+
{buttonContent}
}
/>
diff --git a/web/app/components/main-nav/components/web-apps-section.tsx b/web/app/components/main-nav/components/web-apps-section.tsx
index e12e9e7cce4..a3c0097805f 100644
--- a/web/app/components/main-nav/components/web-apps-section.tsx
+++ b/web/app/components/main-nav/components/web-apps-section.tsx
@@ -369,7 +369,6 @@ const WebAppsSectionContent = () => {
{t(($) => $['operation.confirm'], { ns: 'common' })}
diff --git a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx
index f640fe09c89..c0d281dc4f2 100644
--- a/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx
+++ b/web/app/components/plugins/install-plugin/install-from-github/steps/loaded.tsx
@@ -42,6 +42,7 @@ const Loaded: React.FC = ({
onFailed,
}) => {
const { t } = useTranslation()
+ const installButtonLabelId = React.useId()
const toInstallVersion = payload.version
const pluginId = (payload as Plugin).plugin_id
const { installedInfo, isLoading } = useCheckInstalled({
@@ -167,8 +168,9 @@ const Loaded: React.FC = ({
onClick={handleInstall}
disabled={isLoading}
loading={isInstalling}
+ aria-labelledby={installButtonLabelId}
>
-
+
{t(($) => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], {
ns: 'plugin',
})}
diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx
index 4172af9a228..7ca5277fe40 100644
--- a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx
+++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-oauth-button.spec.tsx
@@ -218,7 +218,6 @@ describe('AddOAuthButton', () => {
await waitFor(() => {
expect(confirmButton).toHaveAttribute('aria-disabled', 'true')
})
- expect(confirmButton).toHaveAttribute('aria-busy', 'true')
expect(within(dialog).getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled()
expect(
within(dialog).getByRole('button', {
diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-visibility-dialog.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-visibility-dialog.tsx
index 93dae561510..1c7364d656d 100644
--- a/web/app/components/plugins/plugin-auth/authorize/oauth-visibility-dialog.tsx
+++ b/web/app/components/plugins/plugin-auth/authorize/oauth-visibility-dialog.tsx
@@ -70,13 +70,7 @@ const OAuthVisibilityDialog = ({
handleOpenChange(false)}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
-
+
{t(($) => $['auth.authorize'], { ns: 'plugin' })}
diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx
index 83b78b8746a..ada6b5e48b2 100644
--- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx
@@ -88,7 +88,7 @@ const HeaderModals: FC = ({
{t(($) => $['operation.cancel'], { ns: 'common' })}
-
+
{t(($) => $['operation.confirm'], { ns: 'common' })}
diff --git a/web/app/components/plugins/plugin-item/action.tsx b/web/app/components/plugins/plugin-item/action.tsx
index 3c7c6d80260..0ae0f0e7094 100644
--- a/web/app/components/plugins/plugin-item/action.tsx
+++ b/web/app/components/plugins/plugin-item/action.tsx
@@ -192,7 +192,7 @@ const Action: FC = ({
{t(($) => $['operation.cancel'], { ns: 'common' })}
-
+
{t(($) => $['operation.confirm'], { ns: 'common' })}
diff --git a/web/app/components/plugins/plugin-mutation-model/index.tsx b/web/app/components/plugins/plugin-mutation-model/index.tsx
index 82c650b394a..f85dd29d613 100644
--- a/web/app/components/plugins/plugin-mutation-model/index.tsx
+++ b/web/app/components/plugins/plugin-mutation-model/index.tsx
@@ -70,12 +70,7 @@ const PluginMutationModal: FC = ({
{modalBottomLeft}
{!mutation.isPending && {cancelButtonText} }
-
+
{confirmButtonText}
diff --git a/web/app/components/plugins/update-plugin/from-market-place.tsx b/web/app/components/plugins/update-plugin/from-market-place.tsx
index d4717e50160..fc93b1cea0a 100644
--- a/web/app/components/plugins/update-plugin/from-market-place.tsx
+++ b/web/app/components/plugins/update-plugin/from-market-place.tsx
@@ -6,7 +6,7 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dif
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge, { BadgeState } from '@/app/components/base/badge/index'
import Card from '@/app/components/plugins/card'
@@ -55,6 +55,7 @@ const UpdatePluginModal = ({
}: Props) => {
const { originalPackageInfo, targetPackageInfo } = payload
const { t } = useTranslation()
+ const upgradeButtonLabelId = React.useId()
const { getIconUrl } = useGetIcon()
const [icon, setIcon] = useState(originalPackageInfo.payload.icon)
useEffect(() => {
@@ -72,13 +73,11 @@ const UpdatePluginModal = ({
const [uploadStep, setUploadStep] = useState(UploadStep.notStarted)
const { handleInstallTaskStart } = usePluginTaskList(payload.category)
- const configBtnText = useMemo(() => {
- return {
- [UploadStep.notStarted]: t(($) => $[`${i18nPrefix}.upgrade`], { ns: 'plugin' }),
- [UploadStep.upgrading]: t(($) => $[`${i18nPrefix}.upgrading`], { ns: 'plugin' }),
- [UploadStep.installed]: t(($) => $[`${i18nPrefix}.close`], { ns: 'plugin' }),
- }[uploadStep]
- }, [t, uploadStep])
+ const configBtnText = {
+ [UploadStep.notStarted]: t(($) => $[`${i18nPrefix}.upgrade`], { ns: 'plugin' }),
+ [UploadStep.upgrading]: t(($) => $[`${i18nPrefix}.upgrading`], { ns: 'plugin' }),
+ [UploadStep.installed]: t(($) => $[`${i18nPrefix}.close`], { ns: 'plugin' }),
+ }[uploadStep]
const handleConfirm = useCallback(async () => {
if (uploadStep === UploadStep.notStarted) {
@@ -210,9 +209,9 @@ const UpdatePluginModal = ({
variant="primary"
loading={uploadStep === UploadStep.upgrading}
onClick={handleConfirm}
- disabled={uploadStep === UploadStep.upgrading}
+ aria-labelledby={upgradeButtonLabelId}
>
- {configBtnText}
+ {configBtnText}
>
diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx
index 2c2b6d6077e..a28a3826278 100644
--- a/web/app/components/rag-pipeline/components/conversion.tsx
+++ b/web/app/components/rag-pipeline/components/conversion.tsx
@@ -135,7 +135,7 @@ const Conversion = () => {
{t(($) => $['operation.confirm'], { ns: 'common' })}
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx
index 657fad0357b..988aa09f92a 100644
--- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx
+++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx
@@ -65,4 +65,17 @@ describe('Document processing actions', () => {
screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i }),
)
})
+
+ it('should keep the pending form submit action focusable when canSubmit becomes false', () => {
+ render(
+ ,
+ )
+
+ expectLoadingButton(
+ screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i }),
+ )
+ })
})
diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/actions.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/actions.tsx
index 1924b115e63..ee74a748d45 100644
--- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/actions.tsx
+++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/actions.tsx
@@ -16,6 +16,7 @@ const Actions = ({ formParams, runDisabled, onBack }: ActionsProps) => {
const { form, isSubmitting, canSubmit } = formParams
const workflowRunningData = useStore((s) => s.workflowRunningData)
const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
+ const loading = isSubmitting || isRunning
return (
@@ -27,8 +28,8 @@ const Actions = ({ formParams, runDisabled, onBack }: ActionsProps) => {
onClick={() => {
form.handleSubmit()
}}
- disabled={runDisabled || isSubmitting || !canSubmit || isRunning}
- loading={isSubmitting || isRunning}
+ disabled={runDisabled || (!canSubmit && !loading)}
+ loading={loading}
>
{t(($) => $['operations.process'], { ns: 'datasetPipeline' })}
diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx
index dee0ce0b00f..70638b05f61 100644
--- a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx
+++ b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx
@@ -87,7 +87,7 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) =
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
+
{t(($) => $.publishButton)}
)
diff --git a/web/app/components/snippets/create-snippet-dialog.tsx b/web/app/components/snippets/create-snippet-dialog.tsx
index ba0f6e572c2..bceabe361bd 100644
--- a/web/app/components/snippets/create-snippet-dialog.tsx
+++ b/web/app/components/snippets/create-snippet-dialog.tsx
@@ -174,7 +174,7 @@ export function CreateSnippetDialog({
diff --git a/web/app/components/snippets/import-snippet-dsl-dialog.tsx b/web/app/components/snippets/import-snippet-dsl-dialog.tsx
index 1e1834a91e0..f6b2a0bbce2 100644
--- a/web/app/components/snippets/import-snippet-dsl-dialog.tsx
+++ b/web/app/components/snippets/import-snippet-dsl-dialog.tsx
@@ -220,7 +220,6 @@ function ImportSnippetDSLDialog({ isOpen, onClose }: ImportSnippetDSLDialogProps
const isSubmitting = importSnippetMutation.isPending || confirmSnippetImportMutation.isPending
const importDisabled =
- isSubmitting ||
!canCreateAndModifySnippet ||
(currentTab === ImportSnippetDSLDialogTab.FromFile && !currentFile) ||
(currentTab === ImportSnippetDSLDialogTab.FromUrl && !dslUrl.trim())
diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx
index aee315bcf62..11a3f9422b1 100644
--- a/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx
+++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx
@@ -46,6 +46,31 @@ describe('GetSchema', () => {
})
})
+ it('keeps the OK label while importing', async () => {
+ let resolveImport!: (value: { schema: string }) => void
+ importSchemaFromURLMock.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveImport = resolve
+ }),
+ )
+ fireEvent.click(screen.getByText('tools.createTool.importFromUrl'))
+ fireEvent.change(screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder'), {
+ target: { value: 'https://example.com' },
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.ok' }))
+
+ const importButton = screen.getByRole('button', { name: 'common.operation.ok' })
+ expect(importButton).toHaveTextContent('common.operation.ok')
+ expect(importButton).toHaveAttribute('aria-disabled', 'true')
+
+ resolveImport({ schema: 'result-schema' })
+ await waitFor(() => {
+ expect(mockOnChange).toHaveBeenCalledWith('result-schema')
+ })
+ })
+
it('selects example schema when example option clicked', () => {
fireEvent.click(screen.getByText('tools.createTool.examples'))
fireEvent.click(screen.getByText(`tools.createTool.exampleOptions.${examples[0].key}`))
diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx
index f369deb6687..c8a3e84298d 100644
--- a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx
+++ b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx
@@ -68,7 +68,7 @@ const GetSchema: FC = ({ onChange }) => {
onClick={handleImportFromUrl}
loading={isParsing}
>
- {isParsing ? '' : t(($) => $['operation.ok'], { ns: 'common' })}
+ {t(($) => $['operation.ok'], { ns: 'common' })}
diff --git a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx
index 260c84671c4..2759df58f85 100644
--- a/web/app/components/tools/edit-custom-collection-modal/test-api.tsx
+++ b/web/app/components/tools/edit-custom-collection-modal/test-api.tsx
@@ -170,7 +170,6 @@ const TestApi: FC
= ({ positionCenter, customCollection, tool, onHide })
variant="primary"
className="mt-4 h-10 w-full"
loading={testing}
- disabled={testing}
onClick={handleTest}
>
{t(($) => $['test.title'], { ns: 'tools' })}
diff --git a/web/app/components/tools/mcp/index.tsx b/web/app/components/tools/mcp/index.tsx
index e69c2dddbde..c8c03e9f47c 100644
--- a/web/app/components/tools/mcp/index.tsx
+++ b/web/app/components/tools/mcp/index.tsx
@@ -215,11 +215,7 @@ const MCPList = ({
{t(($) => $['operation.cancel'], { ns: 'common' })}
-
+
{t(($) => $['operation.confirm'], { ns: 'common' })}
diff --git a/web/app/components/tools/setting/build-in/config-credentials.tsx b/web/app/components/tools/setting/build-in/config-credentials.tsx
index 2a92a99eec8..84deb53f890 100644
--- a/web/app/components/tools/setting/build-in/config-credentials.tsx
+++ b/web/app/components/tools/setting/build-in/config-credentials.tsx
@@ -161,7 +161,6 @@ const ConfigCredential: FC = ({
{!readonly && (
diff --git a/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx
index 71251a80c74..8e5e05fa06c 100644
--- a/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx
+++ b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx
@@ -101,6 +101,7 @@ describe('DSLExportConfirmModal', () => {
await waitFor(() => {
expectLoadingButton(confirmButton)
expect(confirmButton).toHaveTextContent('common.operation.exporting')
+ expect(confirmButton).toHaveAccessibleName('common.operation.exporting')
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled()
})
diff --git a/web/app/components/workflow/dsl-export-confirm-modal.tsx b/web/app/components/workflow/dsl-export-confirm-modal.tsx
index 2c97c9582ca..6a969a44bc2 100644
--- a/web/app/components/workflow/dsl-export-confirm-modal.tsx
+++ b/web/app/components/workflow/dsl-export-confirm-modal.tsx
@@ -33,6 +33,7 @@ export const DSLExportConfirmContent = ({
const [exportSecrets, setExportSecrets] = useState(false)
const [isExporting, setIsExporting] = useState(false)
+ const exportButtonLabelId = React.useId()
const submit = useCallback(async () => {
if (isExporting) return
@@ -131,14 +132,16 @@ export const DSLExportConfirmContent = ({
- {isExporting
- ? t(($) => $['operation.exporting'], { ns: 'common' })
- : exportSecrets
- ? t(($) => $['env.export.export'], { ns: 'workflow' })
- : t(($) => $['env.export.ignore'], { ns: 'workflow' })}
+
+ {isExporting
+ ? t(($) => $['operation.exporting'], { ns: 'common' })
+ : exportSecrets
+ ? t(($) => $['env.export.export'], { ns: 'workflow' })
+ : t(($) => $['env.export.ignore'], { ns: 'workflow' })}
+
diff --git a/web/app/components/workflow/nodes/data-source/before-run-form.tsx b/web/app/components/workflow/nodes/data-source/before-run-form.tsx
index bb1960628b2..52058711c6c 100644
--- a/web/app/components/workflow/nodes/data-source/before-run-form.tsx
+++ b/web/app/components/workflow/nodes/data-source/before-run-form.tsx
@@ -94,7 +94,7 @@ const BeforeRunForm: FC = (props) => {
onClick={handleRunWithSyncDraft}
variant="primary"
loading={isPending}
- disabled={isPending || startRunBtnDisabled}
+ disabled={startRunBtnDisabled}
>
{t(($) => $['singleRun.startRun'], { ns: 'workflow' })}
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx
index 5d730e134cc..d0bf3b339ab 100644
--- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx
@@ -476,7 +476,7 @@ const EmailSenderContent = ({
)}
= ({
-
+
{t(($) => $['operation.cancel'], { ns: 'common' })}
-
+
{t(($) => $['operation.delete'], { ns: 'common' })}
diff --git a/web/app/components/workflow/panel/version-history-panel/empty.tsx b/web/app/components/workflow/panel/version-history-panel/empty.tsx
index 39f1ca8b9be..fbbf841cdb0 100644
--- a/web/app/components/workflow/panel/version-history-panel/empty.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/empty.tsx
@@ -20,7 +20,7 @@ const Empty: FC = ({ onResetFilter }) => {
{t(($) => $['versionHistory.filter.empty'], { ns: 'workflow' })}
-
+
{t(($) => $['versionHistory.filter.reset'], { ns: 'workflow' })}
diff --git a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx
index 7ed0b6d6fa5..47fdb082a49 100644
--- a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx
@@ -48,18 +48,10 @@ const RestoreConfirmModal: FC = ({
-
+
{t(($) => $['operation.cancel'], { ns: 'common' })}
-
+
{t(($) => $['common.restore'], { ns: 'workflow' })}
diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx
index e1046a785ee..fa5f9992520 100644
--- a/web/app/components/workflow/update-dsl-modal.tsx
+++ b/web/app/components/workflow/update-dsl-modal.tsx
@@ -234,7 +234,7 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) =
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
$['operation.cancel'])}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx
index bb4e5fdf9e8..edbde9abdc7 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx
@@ -35,7 +35,7 @@ export function AgentBuildDraftBar({
const collapsedBarRef = useRef(null)
const changesPanelId = useId()
const isActionPending = isApplying || isDiscarding
- const applyDisabled = disabled || isActionPending
+ const applyDisabled = disabled || isDiscarding
const discardDisabled = disabled || isActionPending
const changesLabel = t(($) => $['agentDetail.configure.buildDraft.changesToApply'], {
count: changesCount,
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
index f8b13a78a6c..fe3d48a4db5 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx
@@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
-import { useState } from 'react'
+import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/store'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
@@ -119,9 +119,7 @@ export function AgentConfigurePublishBar({
isPublishing,
})
const publishIsAvailable =
- composerQuery.isSuccess &&
- !isPublishing &&
- (publishableState === 'draft' || publishableState === 'unpublished')
+ composerQuery.isSuccess && (publishableState === 'draft' || publishableState === 'unpublished')
const workflowReferencesQueryOptions =
consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({
input: {
@@ -132,13 +130,13 @@ export function AgentConfigurePublishBar({
context: {
silent: true,
},
- enabled: publishIsAvailable && !selectedVersionSnapshot,
+ enabled: publishIsAvailable && !isPublishing && !selectedVersionSnapshot,
})
useQuery(workflowReferencesQueryOptions)
const restoreVersionMutation = useMutation(
consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions(),
)
- const canPublish = publishIsAvailable
+ const canPublish = publishIsAvailable && !isPublishing
const handleRestoreVersion = (versionId: string) => {
if (restoreVersionMutation.isPending) return
@@ -328,7 +326,7 @@ export function AgentConfigurePublishBar({
metaLabel={currentStateMeta.metaLabel}
showShortcut={currentStateMeta.showShortcut}
statusLabel={currentStateMeta.statusLabel}
- canPublish={canPublish}
+ publishIsAvailable={publishIsAvailable}
onCancelImpact={() => setPublishBarMode({ status: 'compact' })}
onOpenVersions={() => onOpenVersions?.()}
onPublishRequest={requestPublish}
@@ -345,7 +343,7 @@ function PublishBarActions({
metaLabel,
showShortcut,
statusLabel,
- canPublish,
+ publishIsAvailable,
onCancelImpact,
onOpenVersions,
onPublishRequest,
@@ -357,12 +355,13 @@ function PublishBarActions({
metaLabel: string
showShortcut: boolean
statusLabel: string
- canPublish: boolean
+ publishIsAvailable: boolean
onCancelImpact: () => void
onOpenVersions: () => void
onPublishRequest: () => void
}) {
const { t } = useTranslation('agentV2')
+ const publishButtonLabelId = useId()
return (
@@ -402,13 +401,16 @@ function PublishBarActions({
{actionIcon && }
- {actionLabel}
+
+ {actionLabel}
+
{showShortcut && }
diff --git a/web/features/new-rag/__tests__/add-source-page.spec.tsx b/web/features/new-rag/__tests__/add-source-page.spec.tsx
index 6e2b545752a..425d217337d 100644
--- a/web/features/new-rag/__tests__/add-source-page.spec.tsx
+++ b/web/features/new-rag/__tests__/add-source-page.spec.tsx
@@ -182,6 +182,14 @@ const connection = (
version,
})
+function createDeferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise
+ })
+ return { promise, resolve }
+}
+
describe('AddSourcePage', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -562,6 +570,34 @@ describe('AddSourcePage', () => {
expect(screen.queryByDisplayValue('secret-value')).not.toBeInTheDocument()
})
+ it('keeps the connection action focused and explicitly named while pending', async () => {
+ const user = userEvent.setup()
+ const createConnectionDeferred = createDeferred>()
+ clientMock.createConnection.mockReturnValue(createConnectionDeferred.promise)
+
+ render( )
+ await user.click(
+ screen.getByRole('button', { name: /^dataset\.newKnowledge\.configureProvider/ }),
+ )
+ await user.type(screen.getByLabelText(/Api Key/), 'secret-value')
+ const connectButton = screen.getByRole('button', {
+ name: 'dataset.newKnowledge.connectProvider',
+ })
+ await user.click(connectButton)
+
+ const pendingButton = screen.getByRole('button', {
+ name: 'dataset.newKnowledge.connectingProvider',
+ })
+ expect(pendingButton).toBe(connectButton)
+ expect(pendingButton).toHaveAttribute('aria-disabled', 'true')
+ expect(pendingButton).toHaveFocus()
+ await user.click(pendingButton)
+ expect(clientMock.createConnection).toHaveBeenCalledOnce()
+
+ await act(async () => createConnectionDeferred.resolve(connection('active')))
+ await screen.findByRole('status', { name: 'appApi.loading' })
+ })
+
it('releases the parent history guard before the crawl preview owns navigation', async () => {
const user = userEvent.setup()
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => undefined)
@@ -799,6 +835,29 @@ describe('AddSourcePage', () => {
expect(screen.getByText(/dataset\.newKnowledge\.providerConnected/)).toBeInTheDocument()
})
+ it('keeps the refresh action focused and explicitly named while pending', async () => {
+ const user = userEvent.setup()
+ const refreshConnectionDeferred = createDeferred>()
+ queryState.connections.data = { pages: [{ items: [connection('error')] }] }
+ clientMock.refreshConnection.mockReturnValue(refreshConnectionDeferred.promise)
+
+ render( )
+ const refreshButton = screen.getByRole('button', { name: 'common.operation.retry' })
+ await user.click(refreshButton)
+
+ const pendingButton = screen.getByRole('button', {
+ name: 'dataset.newKnowledge.refreshingConnection',
+ })
+ expect(pendingButton).toBe(refreshButton)
+ expect(pendingButton).toHaveAttribute('aria-disabled', 'true')
+ expect(pendingButton).toHaveFocus()
+ await user.click(pendingButton)
+ expect(clientMock.refreshConnection).toHaveBeenCalledOnce()
+
+ await act(async () => refreshConnectionDeferred.resolve(connection('active')))
+ await screen.findByText(/dataset\.newKnowledge\.providerConnected/)
+ })
+
it('reconciles a refresh version race and retries with the server version', async () => {
const user = userEvent.setup()
queryState.connections.data = { pages: [{ items: [connection('error')] }] }
diff --git a/web/features/new-rag/__tests__/document-detail-page.spec.tsx b/web/features/new-rag/__tests__/document-detail-page.spec.tsx
index e08e9cdca6d..e7ae23efa20 100644
--- a/web/features/new-rag/__tests__/document-detail-page.spec.tsx
+++ b/web/features/new-rag/__tests__/document-detail-page.spec.tsx
@@ -677,10 +677,13 @@ describe('DocumentDetailPage', () => {
)
expect(screen.getByRole('button', { name: 'common.operation.retry' })).toHaveFocus()
- await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
+ const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
+ await user.click(retryButton)
revisionsQuery.isFetchingNextPage = true
revisionsQuery.isFetchNextPageError = false
rendered.rerender( )
+ expect(retryButton).toHaveAccessibleName('dataset.newKnowledge.loadMoreRevisions')
+ expect(retryButton).toHaveAttribute('aria-disabled', 'true')
revisionsQuery.isFetchingNextPage = false
revisionsQuery.hasNextPage = false
rendered.rerender( )
@@ -763,13 +766,21 @@ describe('DocumentDetailPage', () => {
chunksQuery.hasNextPage = true
chunksQuery.isFetchNextPageError = true
- render( )
+ const rendered = render(
+ ,
+ )
expect(screen.getByRole('alert')).toHaveTextContent(
'dataset.newKnowledge.documentChunksLoadMoreError',
)
- await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
+ const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
+ await user.click(retryButton)
expect(chunksQuery.fetchNextPage).toHaveBeenCalledOnce()
+ chunksQuery.isFetchingNextPage = true
+ chunksQuery.isFetchNextPageError = false
+ rendered.rerender( )
+ expect(retryButton).toHaveAccessibleName('dataset.newKnowledge.loadMore')
+ expect(retryButton).toHaveAttribute('aria-disabled', 'true')
})
it('keeps remaining chunk pages user-controlled and marks partial document statistics', async () => {
@@ -988,7 +999,7 @@ describe('DocumentDetailPage', () => {
await waitFor(() => expect(screen.getByRole('heading', { level: 1 })).toHaveFocus())
})
- it('keeps re-index visibly busy through invalidation and stale task-list reconciliation', async () => {
+ it('keeps re-index unavailable through invalidation and stale task-list reconciliation', async () => {
const user = userEvent.setup()
let finishInvalidation: (() => void) | undefined
const invalidation = new Promise((resolve) => {
@@ -1000,11 +1011,11 @@ describe('DocumentDetailPage', () => {
const button = screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocument' })
await user.click(button)
- expect(button).toHaveAttribute('aria-busy', 'true')
+ expect(button).toHaveAttribute('aria-disabled', 'true')
expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce()
finishInvalidation?.()
await waitFor(() => expect(toastState.success).toHaveBeenCalled())
- expect(button).toHaveAttribute('aria-busy', 'true')
+ expect(button).toHaveAttribute('aria-disabled', 'true')
await user.click(button)
expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce()
})
@@ -1067,6 +1078,8 @@ describe('DocumentDetailPage', () => {
await Promise.resolve()
await Promise.resolve()
})
+ expect(reindexButton).toHaveAttribute('aria-disabled', 'true')
+ expect(reindexButton).not.toBeDisabled()
submissionTasksQuery.error = new Error('submission discovery failed')
rendered.rerender( )
await act(() => vi.advanceTimersByTimeAsync(30000))
@@ -1082,11 +1095,16 @@ describe('DocumentDetailPage', () => {
expect(timedOutDiscoveryOptions.refetchInterval({ state: { data: { items: [] } } })).toBe(
false,
)
- fireEvent.click(
- within(alert).getByRole('button', {
- name: 'dataset.newKnowledge.checkReindexStatus',
- }),
- )
+ const checkButton = within(alert).getByRole('button', {
+ name: 'dataset.newKnowledge.checkReindexStatus',
+ })
+ const retryButton = within(alert).getByRole('button', {
+ name: 'dataset.newKnowledge.retryReindexDocument',
+ })
+ fireEvent.click(checkButton)
+ expect(checkButton).toHaveAttribute('aria-disabled', 'true')
+ expect(checkButton).not.toBeDisabled()
+ expect(retryButton).toBeDisabled()
expect(reindexButton).toHaveAttribute('data-disabled')
expect(reindexMutation.mutateAsync).toHaveBeenCalledOnce()
expect(submissionTasksQuery.refetch).toHaveBeenCalledOnce()
@@ -1098,12 +1116,19 @@ describe('DocumentDetailPage', () => {
expect(reindexButton).toHaveAttribute('data-disabled')
expect(screen.getByRole('heading', { level: 1 })).toHaveFocus()
- await act(async () => {
- fireEvent.click(
- within(alert).getByRole('button', {
- name: 'dataset.newKnowledge.retryReindexDocument',
+ let finishRetry: ((value: BulkDocumentReindexResult) => void) | undefined
+ reindexMutation.mutateAsync.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishRetry = resolve
}),
- )
+ )
+ fireEvent.click(retryButton)
+ expect(retryButton).toHaveAttribute('aria-disabled', 'true')
+ expect(retryButton).not.toBeDisabled()
+ expect(checkButton).toBeDisabled()
+ await act(async () => {
+ finishRetry?.(queuedReindexResult())
await Promise.resolve()
await Promise.resolve()
})
diff --git a/web/features/new-rag/__tests__/documents-page.spec.tsx b/web/features/new-rag/__tests__/documents-page.spec.tsx
index 1b852f6c403..d0571c81e8a 100644
--- a/web/features/new-rag/__tests__/documents-page.spec.tsx
+++ b/web/features/new-rag/__tests__/documents-page.spec.tsx
@@ -5294,10 +5294,6 @@ describe('DocumentsPage', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }))
expect(await screen.findByText('dataset.newKnowledge.taskActionFailed')).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' })).toHaveAttribute(
- 'aria-busy',
- 'false',
- )
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.retryTask' }),
).not.toHaveAttribute('aria-disabled', 'true')
@@ -5399,7 +5395,7 @@ describe('DocumentsPage', () => {
rendered.unmount()
})
- it('announces upload and re-index operations as busy', async () => {
+ it('keeps upload and re-index actions focusable and unavailable while pending', async () => {
const user = userEvent.setup()
uploadMutation.mutateAsync.mockImplementation(() => new Promise(() => {}))
const emptyPage = render( )
@@ -5409,7 +5405,7 @@ describe('DocumentsPage', () => {
)
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.addDocument' }),
- ).toHaveAttribute('aria-busy', 'true')
+ ).toHaveAttribute('aria-disabled', 'true')
emptyPage.unmount()
reindexMutation.mutateAsync.mockImplementation(() => new Promise(() => {}))
@@ -5419,7 +5415,7 @@ describe('DocumentsPage', () => {
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' }))
expect(
screen.getByRole('button', { name: 'dataset.newKnowledge.reindexDocuments' }),
- ).toHaveAttribute('aria-busy', 'true')
+ ).toHaveAttribute('aria-disabled', 'true')
})
it('locks uploads after a write mutation reveals revoked permission', async () => {
diff --git a/web/features/new-rag/__tests__/website-crawl-preview.spec.tsx b/web/features/new-rag/__tests__/website-crawl-preview.spec.tsx
index f8a3ecf6fde..d52d4a6847c 100644
--- a/web/features/new-rag/__tests__/website-crawl-preview.spec.tsx
+++ b/web/features/new-rag/__tests__/website-crawl-preview.spec.tsx
@@ -97,6 +97,14 @@ const run = (state: string, overrides: Partial = {}): SourceW
...overrides,
})
+function createDeferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise
+ })
+ return { promise, resolve }
+}
+
async function fillValidForm() {
const user = userEvent.setup()
await user.type(screen.getByLabelText(/^dataset\.newKnowledge\.rootUrl/), 'https://docs.dify.ai')
@@ -725,8 +733,9 @@ describe('WebsiteCrawlPreview', () => {
})
it('stops the active run once and keeps pages already discovered', async () => {
+ const cancelDeferred = createDeferred()
clientMock.getRun.mockResolvedValue(run('running', { progressCompleted: 1 }))
- clientMock.cancel.mockResolvedValue(run('canceled', { progressCompleted: 1 }))
+ clientMock.cancel.mockReturnValue(cancelDeferred.promise)
render( )
const user = await fillValidForm()
@@ -736,11 +745,17 @@ describe('WebsiteCrawlPreview', () => {
await user.dblClick(stop)
await waitFor(() => expect(clientMock.cancel).toHaveBeenCalledOnce())
+ const stopping = screen.getByRole('button', { name: 'dataset.newKnowledge.stoppingCrawl' })
+ expect(stopping).toBe(stop)
+ expect(stopping).not.toBeDisabled()
+ expect(stopping).toHaveAttribute('aria-disabled', 'true')
+ expect(stopping).toHaveFocus()
expect(clientMock.cancel).toHaveBeenCalledWith({
body: { reason: 'user_requested' },
params: { id: 'space-1', runId: 'run-1' },
})
expect(screen.getByText('Getting started')).toBeInTheDocument()
+ await act(async () => cancelDeferred.resolve(run('canceled', { progressCompleted: 1 })))
expect(await screen.findByText('dataset.newKnowledge.crawlStopped')).toHaveAttribute(
'role',
'status',
diff --git a/web/features/new-rag/add-source-page.tsx b/web/features/new-rag/add-source-page.tsx
index afec4c8511f..ec9b880dba9 100644
--- a/web/features/new-rag/add-source-page.tsx
+++ b/web/features/new-rag/add-source-page.tsx
@@ -283,6 +283,7 @@ function ConnectionForm({
provider: Provider
}) {
const { t } = useTranslation('dataset')
+ const connectButtonLabelId = useId()
const supportedAuthKinds = getSupportedAuthKinds(provider)
const [authKind, setAuthKind] = useState(supportedAuthKinds[0] ?? 'api-key')
const [configuration, setConfiguration] = useState>({})
@@ -413,10 +414,18 @@ function ConnectionForm({
{t(($) => $['newKnowledge.connectionFailed'])}
)}
-
- {pending
- ? t(($) => $['newKnowledge.connectingProvider'])
- : t(($) => $['newKnowledge.connectProvider'])}
+
+
+ {pending
+ ? t(($) => $['newKnowledge.connectingProvider'])
+ : t(($) => $['newKnowledge.connectProvider'])}
+
)
@@ -486,6 +495,7 @@ function ConnectionProblem({
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
+ const refreshButtonLabelId = useId()
const [pending, setPending] = useState(false)
const [error, setError] = useState(false)
@@ -528,10 +538,17 @@ function ConnectionProblem({
{t(($) => $['newKnowledge.connectionRefreshFailed'])}
)}
- void refresh()} disabled={pending}>
- {pending
- ? t(($) => $['newKnowledge.refreshingConnection'])
- : tCommon(($) => $['operation.retry'])}
+ void refresh()}
+ loading={pending}
+ aria-labelledby={refreshButtonLabelId}
+ >
+
+ {pending
+ ? t(($) => $['newKnowledge.refreshingConnection'])
+ : tCommon(($) => $['operation.retry'])}
+
)
@@ -570,7 +587,7 @@ function ProvisioningConnection({
{t(($) => $['newKnowledge.connectionRefreshFailed'])}
)}
- void refresh()} disabled={pending}>
+ void refresh()}>
{t(($) => $['newKnowledge.refreshConnectionStatus'])}
diff --git a/web/features/new-rag/components/add-source-exit-dialog.tsx b/web/features/new-rag/components/add-source-exit-dialog.tsx
index cd64cb7079b..036ff4298d1 100644
--- a/web/features/new-rag/components/add-source-exit-dialog.tsx
+++ b/web/features/new-rag/components/add-source-exit-dialog.tsx
@@ -55,7 +55,7 @@ export function AddSourceExitDialog({
{tCommon(($) => $['operation.cancel'])}
-
+
{t(($) => $['newKnowledge.discardDraftConfirm'])}
diff --git a/web/features/new-rag/crawl-selection-form.tsx b/web/features/new-rag/crawl-selection-form.tsx
index e16bfcf02ad..baaf540390c 100644
--- a/web/features/new-rag/crawl-selection-form.tsx
+++ b/web/features/new-rag/crawl-selection-form.tsx
@@ -419,7 +419,7 @@ function ReadyCrawlSelectionForm({
type="button"
variant="tertiary"
size="small"
- disabled={submissionLocked}
+ disabled={submitting || policyUncertain || selectionUncertain}
loading={busy}
onClick={onRecrawl}
>
@@ -552,7 +552,7 @@ function ReadyCrawlSelectionForm({
diff --git a/web/features/new-rag/document-chunk-tree.tsx b/web/features/new-rag/document-chunk-tree.tsx
index bc32b68221b..045234f49ca 100644
--- a/web/features/new-rag/document-chunk-tree.tsx
+++ b/web/features/new-rag/document-chunk-tree.tsx
@@ -40,6 +40,7 @@ export function DocumentChunkTreePanel({
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const treeHeadingId = useId()
+ const loadMoreLabelId = useId()
const [collapsedChunkIds, setCollapsedChunkIds] = useState>(() => new Set())
const [focusedChunkId, setFocusedChunkId] = useState()
const [treeHasFocus, setTreeHasFocus] = useState(false)
@@ -264,13 +265,15 @@ export function DocumentChunkTreePanel({
)}
- {isFetchNextPageError
- ? tCommon(($) => $['operation.retry'])
- : t(($) => $['newKnowledge.loadMore'])}
+
+ {isFetchNextPageError
+ ? tCommon(($) => $['operation.retry'])
+ : t(($) => $['newKnowledge.loadMore'])}
+
)}
diff --git a/web/features/new-rag/document-detail-header.tsx b/web/features/new-rag/document-detail-header.tsx
index 92c87613766..701dc2a2f1e 100644
--- a/web/features/new-rag/document-detail-header.tsx
+++ b/web/features/new-rag/document-detail-header.tsx
@@ -13,7 +13,7 @@ import {
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
-import { useEffect, useRef } from 'react'
+import { useEffect, useId, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
@@ -31,7 +31,6 @@ export function DocumentDetailHeader({
reindexDisabledReasonId,
reindexing,
revisions,
- taskIsActive,
titleRef,
}: {
backPath: string
@@ -47,12 +46,12 @@ export function DocumentDetailHeader({
reindexDisabledReasonId?: string
reindexing: boolean
revisions: Array>
- taskIsActive: boolean
titleRef: RefObject
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const revisionTriggerRef = useRef(null)
+ const loadMoreRevisionLabelId = useId()
const loadMoreRequestedRef = useRef(false)
const wasFetchingNextPageRef = useRef(false)
useEffect(() => {
@@ -114,20 +113,21 @@ export function DocumentDetailHeader({
)}
{(hasNextRevisionPage || isFetchNextRevisionPageError) && (
{
loadMoreRequestedRef.current = true
fetchNextRevisionPage()
}}
>
- {isFetchNextRevisionPageError
- ? tCommon(($) => $['operation.retry'])
- : t(($) => $['newKnowledge.loadMoreRevisions'])}
+
+ {isFetchNextRevisionPageError
+ ? tCommon(($) => $['operation.retry'])
+ : t(($) => $['newKnowledge.loadMoreRevisions'])}
+
)}
void setSelectedRevision(revision)}
reindexDisabled={
!canEdit ||
- reindexBusy ||
- submissionPending ||
taskIsActive ||
tasksPending ||
isFetchingNextTaskPage ||
@@ -215,7 +213,6 @@ export function DocumentDetailPage({
reindexDisabledReasonId={!hasEditPermission ? REINDEX_RESTRICTION_ID : undefined}
reindexing={reindexBusy || submissionPending}
revisions={availableRevisions}
- taskIsActive={taskIsActive}
titleRef={titleRef}
/>
{!hasEditPermission && (
diff --git a/web/features/new-rag/document-detail-status.tsx b/web/features/new-rag/document-detail-status.tsx
index 23c2118f6f8..2a02459a776 100644
--- a/web/features/new-rag/document-detail-status.tsx
+++ b/web/features/new-rag/document-detail-status.tsx
@@ -1,7 +1,7 @@
import type { DocumentProcessingTask } from '@dify/contracts/knowledge-fs/types.gen'
import type { RefObject } from 'react'
import { Button } from '@langgenius/dify-ui/button'
-import { useEffect, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export function DocumentDetailStatus({
@@ -49,6 +49,26 @@ export function DocumentDetailStatus({
const { t: tCommon } = useTranslation('common')
const permissionRetryRef = useRef(null)
const permissionRecoveryWasNeededRef = useRef(false)
+ const pendingRecoveryActionRef = useRef<'check' | 'retry' | null>(null)
+ const [pendingRecoveryAction, setPendingRecoveryAction] = useState<'check' | 'retry' | null>(null)
+ const recoveryBusy = submissionRecoveryBusy || pendingRecoveryAction !== null
+
+ const handleSubmissionRecovery = async (
+ action: 'check' | 'retry',
+ recover: () => Promise,
+ ) => {
+ if (submissionRecoveryBusy || pendingRecoveryActionRef.current) return
+
+ pendingRecoveryActionRef.current = action
+ setPendingRecoveryAction(action)
+ try {
+ await recover()
+ } finally {
+ pendingRecoveryActionRef.current = null
+ setPendingRecoveryAction(null)
+ titleRef.current?.focus()
+ }
+ }
useEffect(() => {
if (permissionRecoveryNeeded && !permissionRecoveryWasNeededRef.current)
@@ -102,7 +122,6 @@ export function DocumentDetailStatus({
{t(($) => $['newKnowledge.documentPermissionRestricted'])}
void retryWritePermission().then((recovered) => {
@@ -145,7 +164,7 @@ export function DocumentDetailStatus({
)}
- {submissionTimedOut && (
+ {(submissionTimedOut || pendingRecoveryAction) && (
{t(($) => $['newKnowledge.documentReindexConfirmationDelayed'])}
- void recheckTimedOutSubmission().finally(() => titleRef.current?.focus())
- }
+ loading={pendingRecoveryAction === 'check'}
+ disabled={recoveryBusy && pendingRecoveryAction !== 'check'}
+ onClick={() => void handleSubmissionRecovery('check', recheckTimedOutSubmission)}
>
{t(($) => $['newKnowledge.checkReindexStatus'])}
- void retryTimedOutSubmission().finally(() => titleRef.current?.focus())
- }
+ loading={pendingRecoveryAction === 'retry'}
+ disabled={recoveryBusy && pendingRecoveryAction !== 'retry'}
+ onClick={() => void handleSubmissionRecovery('retry', retryTimedOutSubmission)}
>
{t(($) => $['newKnowledge.retryReindexDocument'])}
diff --git a/web/features/new-rag/document-list.tsx b/web/features/new-rag/document-list.tsx
index 34e556f2c85..d5364439657 100644
--- a/web/features/new-rag/document-list.tsx
+++ b/web/features/new-rag/document-list.tsx
@@ -275,7 +275,6 @@ export function DocumentsEmpty({
$['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
- aria-busy={isFetchingNextDocumentPage}
loading={isFetchingNextDocumentPage}
onBlur={(event) => {
if (event.relatedTarget) restoreLoadMoreFocusRef.current = false
@@ -610,7 +607,6 @@ export function DocumentsList({
{
if (event.relatedTarget) restoreLoadMoreFocusRef.current = false
@@ -659,7 +655,6 @@ export function DocumentBulkActions({
>
$['operation.retry'])} · ${t(($) => $['newKnowledge.permissionLoadFailed'])}`}
- aria-busy={workspacePermissionKeysFetching}
loading={workspacePermissionKeysFetching}
size="small"
onBlur={(event) => {
@@ -1972,7 +1971,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
$['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
- aria-busy={documentsQuery.isRefetching}
loading={documentsQuery.isRefetching}
size="small"
onBlur={(event) => {
@@ -2005,7 +2003,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
? t(($) => $['newKnowledge.sourcesErrorDescription'])
: t(($) => $['newKnowledge.tasksErrorDescription'])
}`}
- aria-busy={dependencyRetryFetching}
loading={dependencyRetryFetching}
size="small"
onBlur={(event) => {
@@ -2051,7 +2048,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
$['operation.retry'])} · ${documentsRecoveryDescription}`}
- aria-busy={documentsQuery.isFetching}
className="mt-4"
loading={documentsQuery.isFetching}
onBlur={(event) => {
@@ -2085,7 +2081,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
? t(($) => $['newKnowledge.tasksErrorDescription'])
: t(($) => $['newKnowledge.sourcesErrorDescription'])
}`}
- aria-busy={blockingDependencyRetryFetching}
className="mt-4"
loading={blockingDependencyRetryFetching}
onBlur={(event) => {
diff --git a/web/features/new-rag/knowledge-space-shell.tsx b/web/features/new-rag/knowledge-space-shell.tsx
index 9822a3d8643..8649c00c05c 100644
--- a/web/features/new-rag/knowledge-space-shell.tsx
+++ b/web/features/new-rag/knowledge-space-shell.tsx
@@ -1,7 +1,7 @@
'use client'
import type { ReactNode } from 'react'
-import { Button } from '@langgenius/dify-ui/button'
+import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery } from '@tanstack/react-query'
@@ -105,9 +105,9 @@ export function KnowledgeSpaceShell({
)}
- }>
+
{t(($) => $['newKnowledge.backToList'])}
-
+
{!notFound && (
void knowledgeSpaceQuery.refetch()}>
{tCommon(($) => $['operation.retry'])}
diff --git a/web/features/new-rag/new-knowledge-list.tsx b/web/features/new-rag/new-knowledge-list.tsx
index cb50a0ca925..eae1e017e68 100644
--- a/web/features/new-rag/new-knowledge-list.tsx
+++ b/web/features/new-rag/new-knowledge-list.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Button } from '@langgenius/dify-ui/button'
+import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
@@ -135,15 +135,17 @@ export function NewKnowledgeList({
{canCreate && (
- }
- variant="primary"
- size="medium"
- className="px-2 shadow-xs"
+
{createLabel}
-
+
)}
diff --git a/web/features/new-rag/processing-tasks-drawer.tsx b/web/features/new-rag/processing-tasks-drawer.tsx
index 15bf639c115..3260cd7dd83 100644
--- a/web/features/new-rag/processing-tasks-drawer.tsx
+++ b/web/features/new-rag/processing-tasks-drawer.tsx
@@ -428,7 +428,6 @@ export function ProcessingTasksDrawer({
$['operation.retry'])} · ${t(($) => $['newKnowledge.permissionLoadFailed'])}`}
- aria-busy={permissionQueryFetching}
className="mt-3"
loading={permissionQueryFetching}
size="small"
@@ -452,7 +451,6 @@ export function ProcessingTasksDrawer({
$['operation.retry'])} · ${t(($) => $['newKnowledge.tasksErrorDescription'])}`}
- aria-busy={taskQueryFetching}
className="mt-3"
loading={taskQueryFetching}
size="small"
@@ -476,7 +474,6 @@ export function ProcessingTasksDrawer({
$['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
- aria-busy={documentQueryFetching}
className="mt-3"
loading={documentQueryFetching}
size="small"
@@ -560,8 +557,6 @@ export function ProcessingTasksDrawer({
: undefined
}
size="small"
- aria-busy={pendingActions.has(task.id)}
- disabled={pendingActions.has(task.id)}
loading={pendingActions.has(task.id)}
onBlur={(event) => {
if (event.relatedTarget) focusedTaskActionRef.current = null
@@ -581,8 +576,6 @@ export function ProcessingTasksDrawer({
: undefined
}
size="small"
- aria-busy={pendingActions.has(task.id)}
- disabled={pendingActions.has(task.id)}
loading={pendingActions.has(task.id)}
onBlur={(event) => {
if (event.relatedTarget) focusedTaskActionRef.current = null
@@ -608,7 +601,6 @@ export function ProcessingTasksDrawer({
{
loadMoreRequestedRef.current = false
diff --git a/web/features/new-rag/sources-page.tsx b/web/features/new-rag/sources-page.tsx
index f394c94208f..f4acb401841 100644
--- a/web/features/new-rag/sources-page.tsx
+++ b/web/features/new-rag/sources-page.tsx
@@ -192,7 +192,6 @@ function SourceActions({
void onRemove().then((removed) => {
if (removed) setRemoveDialogOpen(false)
@@ -375,7 +374,7 @@ function SourceRow({
size="small"
variant="secondary"
loading={pendingAction === 'sync'}
- disabled={Boolean(pendingAction)}
+ disabled={pendingAction !== undefined && pendingAction !== 'sync'}
onClick={() => void syncSource()}
>
{tCommon(($) => $['operation.retry'])}
diff --git a/web/features/new-rag/website-crawl-preview.tsx b/web/features/new-rag/website-crawl-preview.tsx
index b25dd2a4001..171f96e725b 100644
--- a/web/features/new-rag/website-crawl-preview.tsx
+++ b/web/features/new-rag/website-crawl-preview.tsx
@@ -295,6 +295,8 @@ export function WebsiteCrawlPreview({
const { t } = useTranslation('dataset')
const router = useRouter()
const rootUrlErrorId = useId()
+ const primaryActionLabelId = useId()
+ const stopButtonLabelId = useId()
const [rootUrl, setRootUrl] = useState(initialDraft?.rootUrl ?? '')
const [sourceName, setSourceName] = useState(initialDraft?.sourceName ?? '')
const [urlTouched, setUrlTouched] = useState(false)
@@ -403,7 +405,8 @@ export function WebsiteCrawlPreview({
run && !starting && !stopping && !pollPaused && (active || !pagesLoaded),
)
const runId = run?.id
- const locked = starting || stopping || active || successfulPreview || uncertainOperation
+ const workflowUnavailable = stopping || active || successfulPreview || uncertainOperation
+ const locked = starting || workflowUnavailable
const dirty = Boolean(
rootUrl || sourceName || run || !includeSubpages || pageLimit !== DEFAULT_PAGE_LIMIT,
)
@@ -1162,11 +1165,14 @@ export function WebsiteCrawlPreview({
className="mt-4 w-full"
disabled={
!configuration ||
- (locked && requestError !== 'POLL_FAILED' && !canReconcileUncertainOperation)
+ (workflowUnavailable &&
+ requestError !== 'POLL_FAILED' &&
+ !canReconcileUncertainOperation)
}
loading={starting}
+ aria-labelledby={primaryActionLabelId}
>
- {primaryLabel}
+ {primaryLabel}
)}
@@ -1193,12 +1199,15 @@ export function WebsiteCrawlPreview({
variant="tertiary"
size="small"
className="ml-auto shrink-0"
- disabled={stopping}
+ loading={stopping}
+ aria-labelledby={stopButtonLabelId}
onClick={() => void stop()}
>
- {stopping
- ? t(($) => $['newKnowledge.stoppingCrawl'])
- : t(($) => $['newKnowledge.stopCrawl'])}
+
+ {stopping
+ ? t(($) => $['newKnowledge.stoppingCrawl'])
+ : t(($) => $['newKnowledge.stopCrawl'])}
+
{requestError === 'CANCEL_FAILED' && (
@@ -1333,11 +1342,7 @@ export function WebsiteCrawlPreview({
{t(($) => $['newKnowledge.keepEditing'])}
- void discardAndCancel()}
- >
+ void discardAndCancel()}>
{t(($) => $['newKnowledge.discardSourceChangesConfirm'])}
diff --git a/web/features/skills/detail/sidebar-actions.tsx b/web/features/skills/detail/sidebar-actions.tsx
index 06f2cc59ba2..2b6189d5de6 100644
--- a/web/features/skills/detail/sidebar-actions.tsx
+++ b/web/features/skills/detail/sidebar-actions.tsx
@@ -71,10 +71,10 @@ function SkillDetailDeleteDialog({
})
const references = referencesQuery.data?.data ?? []
const referenceCount = Math.max(detail.reference_count ?? 0, references.length)
- const isDeleteDisabled =
- deleteMutation.isPending ||
+ const isDeleteUnavailable =
(open && (referencesQuery.isFetching || !referencesQuery.isSuccess)) ||
(referenceCount > 0 && confirmDeleteInput !== detail.display_name)
+ const isDeleteDisabled = deleteMutation.isPending || isDeleteUnavailable
const description =
referenceCount > 0
? t(
@@ -186,7 +186,7 @@ function SkillDetailDeleteDialog({
{tCommon(($) => (referenceCount > 0 ? $['operation.confirm'] : $['operation.delete']))}
diff --git a/web/features/skills/page.tsx b/web/features/skills/page.tsx
index 3cc34a36e00..0aec1f3509d 100644
--- a/web/features/skills/page.tsx
+++ b/web/features/skills/page.tsx
@@ -274,8 +274,7 @@ function DeleteSkillDialog({
})
const references = referencesQuery.data?.data ?? []
const referenceCount = Math.max(skill.reference_count ?? 0, references.length)
- const isDeleteDisabled =
- deleteMutation.isPending ||
+ const isDeleteUnavailable =
(open && (referencesQuery.isFetching || !referencesQuery.isSuccess)) ||
(referenceCount > 0 && confirmDeleteInput !== skill.display_name)
const description =
@@ -290,7 +289,7 @@ function DeleteSkillDialog({
: t(($) => $['skillManagement.deleteDialog.description'])
const handleDelete = () => {
- if (isDeleteDisabled) return
+ if (deleteMutation.isPending || isDeleteUnavailable) return
deleteMutation.mutate(
{
@@ -388,7 +387,7 @@ function DeleteSkillDialog({
{tCommon(($) => $['operation.delete'])}
@@ -674,8 +673,6 @@ function SkillsToolbar({
}) {
const { t } = useTranslation('skill')
const [keyword, setKeyword] = useQueryState(skillQueryParamNames.keyword, skillKeywordQueryParser)
- const isMutating = creating || importing
-
return (
@@ -692,7 +689,7 @@ function SkillsToolbar({
{canEdit && (
@@ -704,7 +701,7 @@ function SkillsToolbar({
@@ -889,7 +886,7 @@ export default function SkillsPage() {
useDocumentTitle(t(($) => $['skillManagement.title']))
const handleCreate = () => {
- if (createMutation.isPending) return
+ if (createMutation.isPending || importMutation.isPending) return
createMutation.mutate(
{
@@ -914,7 +911,7 @@ export default function SkillsPage() {
}
const handleFileChange = (file: File | undefined) => {
- if (!file || importMutation.isPending) return
+ if (!file || importMutation.isPending || createMutation.isPending) return
importMutation.mutate(
{