From 1b4ae3bcd2e6158b94758380a7600a818a9e6cb2 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:39:50 +0000 Subject: [PATCH] fix(button): align loading accessibility contract (#41823) --- .../references/accessibility-ui.md | 7 +- .../docs/accessible-names-and-descriptions.md | 8 +-- .../src/alert-dialog/index.stories.tsx | 22 +++++- packages/dify-ui/src/button/README.md | 67 +++++++++---------- packages/dify-ui/src/button/index.stories.tsx | 42 +++++++++--- packages/dify-ui/src/button/index.tsx | 5 ++ .../webapp-reset-password/check-code/page.tsx | 8 +-- .../webapp-reset-password/page.tsx | 1 - .../webapp-signin/check-code/page.tsx | 8 +-- .../components/mail-and-code-auth.tsx | 2 +- .../account-page/AvatarWithEdit.tsx | 2 +- web/app/account/oauth/authorize/page.tsx | 2 +- .../add-access-subject-popover.tsx | 2 +- .../app-sidebar/app-info/app-info-modals.tsx | 10 ++- .../app/annotation/batch-action.tsx | 6 +- .../access-control-form.tsx | 1 - .../app/app-publisher/version-info-modal.tsx | 6 +- .../environment-table/deploy-menu/index.tsx | 1 - .../app/deploy/environment-table/index.tsx | 1 - .../undeploy-confirm-dialog/index.tsx | 7 +- .../components/apps/app-card/interactions.tsx | 5 +- ...import-from-marketplace-template-modal.tsx | 2 +- .../components/base/app-icon-picker/index.tsx | 8 +-- .../form/__tests__/actions.spec.tsx | 8 +++ .../base/form/components/form/actions.tsx | 2 +- .../website/base/__tests__/url-input.spec.tsx | 36 ++-------- .../create/website/base/url-input.tsx | 2 +- .../firecrawl/__tests__/index.spec.tsx | 5 +- .../documents/components/operations.tsx | 6 +- .../base/options/__tests__/index.spec.tsx | 54 ++++++++------- .../website-crawl/base/options/index.tsx | 12 ++-- .../detail/completed/common/batch-action.tsx | 6 +- .../datasets/settings/form/index.tsx | 2 +- .../__tests__/compliance.spec.tsx | 18 +++-- .../header/account-dropdown/compliance.tsx | 24 ++++--- .../__tests__/index.spec.tsx | 27 ++++++++ .../edit-workspace-modal/index.tsx | 14 ++-- .../members-page/invite-modal/index.tsx | 8 +-- .../__tests__/config-model.spec.tsx | 4 +- .../model-auth/config-model.tsx | 9 +-- .../__tests__/marketplace-section.spec.tsx | 39 +++++++++++ .../model-selector/marketplace-section.tsx | 22 +++--- .../provider-card-actions.tsx | 2 +- .../role-list/copy-members-confirm-dialog.tsx | 2 +- .../__tests__/index.spec.tsx | 5 ++ .../workflow-log-archives-page/index.tsx | 16 ++--- .../main-nav/components/web-apps-section.tsx | 1 - .../install-from-github/steps/loaded.tsx | 4 +- .../__tests__/add-oauth-button.spec.tsx | 1 - .../authorize/oauth-visibility-dialog.tsx | 8 +-- .../components/header-modals.tsx | 2 +- .../components/plugins/plugin-item/action.tsx | 2 +- .../plugins/plugin-mutation-model/index.tsx | 7 +- .../update-plugin/from-market-place.tsx | 19 +++--- .../rag-pipeline/components/conversion.tsx | 2 +- .../__tests__/actions.spec.tsx | 13 ++++ .../document-processing/actions.tsx | 5 +- .../components/update-dsl-modal.tsx | 2 +- .../components/snippet-header/index.tsx | 7 +- .../snippets/create-snippet-dialog.tsx | 2 +- .../snippets/import-snippet-dsl-dialog.tsx | 1 - .../__tests__/get-schema.spec.tsx | 25 +++++++ .../get-schema.tsx | 2 +- .../edit-custom-collection-modal/test-api.tsx | 1 - web/app/components/tools/mcp/index.tsx | 6 +- .../setting/build-in/config-credentials.tsx | 1 - .../dsl-export-confirm-modal.spec.tsx | 1 + .../workflow/dsl-export-confirm-modal.tsx | 15 +++-- .../nodes/data-source/before-run-form.tsx | 2 +- .../delivery-method/test-email-sender.tsx | 2 +- .../delete-confirm-modal.tsx | 11 +-- .../panel/version-history-panel/empty.tsx | 2 +- .../restore-confirm-modal.tsx | 12 +--- .../components/workflow/update-dsl-modal.tsx | 2 +- .../confirm-clear-session-dialog.tsx | 2 +- .../orchestrate/build-draft-bar.tsx | 2 +- .../orchestrate/publish-bar/index.tsx | 24 ++++--- .../__tests__/add-source-page.spec.tsx | 59 ++++++++++++++++ .../__tests__/document-detail-page.spec.tsx | 57 +++++++++++----- .../new-rag/__tests__/documents-page.spec.tsx | 10 +-- .../__tests__/website-crawl-preview.spec.tsx | 17 ++++- web/features/new-rag/add-source-page.tsx | 35 +++++++--- .../components/add-source-exit-dialog.tsx | 2 +- web/features/new-rag/crawl-selection-form.tsx | 4 +- web/features/new-rag/document-chunk-tree.tsx | 11 +-- .../new-rag/document-detail-header.tsx | 16 ++--- web/features/new-rag/document-detail-page.tsx | 3 - .../new-rag/document-detail-status.tsx | 40 +++++++---- web/features/new-rag/document-list.tsx | 5 -- web/features/new-rag/documents-page.tsx | 5 -- .../new-rag/knowledge-space-shell.tsx | 6 +- web/features/new-rag/new-knowledge-list.tsx | 16 +++-- .../new-rag/processing-tasks-drawer.tsx | 8 --- web/features/new-rag/sources-page.tsx | 3 +- .../new-rag/website-crawl-preview.tsx | 29 ++++---- .../skills/detail/sidebar-actions.tsx | 6 +- web/features/skills/page.tsx | 17 ++--- 97 files changed, 637 insertions(+), 444 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/marketplace-section.spec.tsx 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 @@ -62,44 +52,55 @@ state only to `loading`: Keep independent availability conditions in `disabled`: ```tsx - ``` -Do not repeat the same pending state in `disabled`: +Do not repeat the pending state in `disabled`: ```tsx -// Incorrect: loading already blocks activation. - - -// Incorrect: keep only the independent availability condition in disabled. - // Correct. - ``` -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 + +``` + +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() + + +``` + +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 ( + + ) +} + 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' }) || ''} /> - 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() {
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/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/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 ( ) 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 (
{ + {isPending && ( + + )} + ) } @@ -154,6 +157,7 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp {icon} diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx index 8db9f8142ba..41e19f28878 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx @@ -115,6 +115,33 @@ describe('EditWorkspaceModal', () => { expect(mockOnCancel).not.toHaveBeenCalled() }) + it('should expose the saving label and prevent duplicate form submission', async () => { + const user = userEvent.setup() + let rejectUpdate!: (reason: Error) => void + vi.mocked(updateWorkspaceInfo).mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectUpdate = reject + }), + ) + renderModal() + const input = screen.getByLabelText(/account\.workspaceName/i) + await user.clear(input) + await user.type(input, 'Renamed Workspace') + + await user.click(getSaveButton()) + + const savingButton = screen.getByRole('button', { name: /operation\.saving/i }) + expect(savingButton).toHaveAttribute('aria-disabled', 'true') + fireEvent.submit(savingButton.closest('form')!) + expect(updateWorkspaceInfo).toHaveBeenCalledOnce() + + rejectUpdate(new Error('update failed')) + await waitFor(() => { + expect(screen.getByRole('button', { name: /operation\.save/i })).toBeEnabled() + }) + }) + it('should show error toast when update fails', async () => { const user = userEvent.setup() diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx index 032318f6cc6..cd01e2f7d06 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx @@ -22,10 +22,11 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { const [isSubmitting, setIsSubmitting] = useState(false) const inputId = useId() const errorId = useId() + const saveButtonLabelId = useId() const normalizedName = name.trim() const hasChanges = normalizedName !== currentWorkspace.name const hasError = normalizedName.length === 0 - const isSaveDisabled = !isCurrentWorkspaceOwner || !hasChanges || hasError || isSubmitting + const isSaveUnavailable = !isCurrentWorkspaceOwner || !hasChanges || hasError const nameErrorMessage = useMemo(() => { if (!hasError) return '' return t(($) => $['errorMsg.fieldRequired'], { @@ -34,7 +35,7 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { }) }, [hasError, t]) const changeWorkspaceInfo = async () => { - if (isSaveDisabled) return + if (isSubmitting || isSaveUnavailable) return setIsSubmitting(true) try { await updateWorkspaceInfo({ @@ -119,10 +120,15 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { size="large" type="submit" variant="primary" - disabled={isSaveDisabled} + disabled={isSaveUnavailable} loading={isSubmitting} + aria-labelledby={saveButtonLabelId} > - {t(($) => $[isSubmitting ? 'operation.saving' : 'operation.save'], { ns: 'common' })} + + {t(($) => $[isSubmitting ? 'operation.saving' : 'operation.save'], { + ns: 'common', + })} + 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} )} - @@ -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 (
  • @@ -78,24 +80,20 @@ function MarketplaceSection({ )}
  • 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({ } /> 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 = ({ - 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 && } -
    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) =
    ) 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/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/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' })}
    -
    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) =
    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'])}

    )} - ) @@ -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'])}

    )} - ) @@ -570,7 +587,7 @@ function ProvisioningConnection({ {t(($) => $['newKnowledge.connectionRefreshFailed'])}

    )} - 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-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) && ( )} 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({ + {!notFound && ( + )} 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({ )} @@ -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 && (