fix(button): align loading accessibility contract (#41823)

This commit is contained in:
yyh 2026-09-04 11:39:50 +00:00 committed by GitHub
parent 2972468bc1
commit 1b4ae3bcd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 637 additions and 444 deletions

View File

@ -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.

View File

@ -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

View File

@ -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 = () => {
<AlertDialogCancelButton variant="secondary" disabled={pending}>
Cancel
</AlertDialogCancelButton>
<AlertDialogConfirmButton tone="default" loading={pending} onClick={handleConfirm}>
{pending ? 'Archiving…' : 'Archive'}
<AlertDialogConfirmButton
tone="default"
loading={pending}
aria-labelledby={confirmLabelId}
onClick={handleConfirm}
>
<span id={confirmLabelId}>{pending ? 'Archiving…' : 'Archive'}</span>
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
@ -191,4 +197,16 @@ const LoadingConfirmDemo = () => {
export const LoadingConfirm: Story = {
render: () => <LoadingConfirmDemo />,
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()
},
}

View File

@ -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
<Button loading={isSaving}>Save</Button>
@ -62,44 +52,55 @@ state only to `loading`:
Keep independent availability conditions in `disabled`:
```tsx
<Button loading={isSaving} disabled={!canSave}>
<Button loading={isSaving} disabled={!canManageSettings}>
Save
</Button>
```
Do not repeat the same pending state in `disabled`:
Do not repeat the pending state in `disabled`:
```tsx
// Incorrect: loading already blocks activation.
<Button loading={isSaving} disabled={isSaving}>
Save
</Button>
// Incorrect: keep only the independent availability condition in disabled.
<Button loading={isSaving} disabled={isSaving || !canSave}>
// Incorrect: loading already handles isSaving.
<Button loading={isSaving} disabled={isSaving || !canManageSettings}>
Save
</Button>
// Correct.
<Button loading={isSaving} disabled={!canSave}>
<Button loading={isSaving} disabled={!canManageSettings}>
Save
</Button>
```
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
<Button loading={isSaving}>Save</Button>
```
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()
<Button loading={isSaving} aria-labelledby={labelId}>
<span id={labelId}>{isSaving ? 'Saving' : 'Save'}</span>
</Button>
```
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

View File

@ -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<ButtonProps, 'onClick' | 'variant'>
function LoadingButtonExample({ onClick, variant }: LoadingButtonExampleProps) {
const [loading, setLoading] = React.useState(false)
const labelId = React.useId()
return (
<Button
aria-labelledby={labelId}
loading={loading}
onClick={(event) => {
onClick?.(event)
setLoading(true)
}}
variant={variant}
>
<span id={labelId}>{loading ? 'Saving' : 'Save'}</span>
</Button>
)
}
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 }) => <LoadingButtonExample onClick={onClick} variant={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.',
},
},
},

View File

@ -105,6 +105,11 @@ const buttonVariants = cva(
type ButtonProps = Omit<BaseButtonNS.Props, 'className'> &
VariantProps<typeof buttonVariants> & {
/**
* 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
}

View File

@ -94,13 +94,7 @@ export default function CheckCode() {
className="mt-1"
placeholder={t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) || ''}
/>
<Button
loading={loading}
disabled={loading}
className="my-3 w-full"
variant="primary"
onClick={verify}
>
<Button loading={loading} className="my-3 w-full" variant="primary" onClick={verify}>
{t(($) => $['checkCode.verify'], { ns: 'login' })}
</Button>
<Countdown onResend={resendCode} />

View File

@ -90,7 +90,6 @@ export default function CheckCode() {
<div className="mt-3">
<Button
loading={loading}
disabled={loading}
variant="primary"
className="w-full"
onClick={handleGetEMailVerificationCode}

View File

@ -145,13 +145,7 @@ export default function CheckCode() {
className="mt-1"
placeholder={t(($) => $['checkCode.verificationCodePlaceholder'], { ns: 'login' }) || ''}
/>
<Button
type="submit"
loading={loading}
disabled={loading}
className="my-3 w-full"
variant="primary"
>
<Button type="submit" loading={loading} className="my-3 w-full" variant="primary">
{t(($) => $['checkCode.verify'], { ns: 'login' })}
</Button>
<Countdown onResend={resendCode} />

View File

@ -89,7 +89,7 @@ export default function MailAndCodeAuth() {
<Button
type="submit"
loading={loading}
disabled={loading || !email}
disabled={!email}
variant="primary"
className="w-full"
>

View File

@ -157,7 +157,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
<Button
variant="primary"
className="min-w-0 flex-1"
disabled={uploading || !inputImageInfo}
disabled={!inputImageInfo}
loading={uploading}
onClick={handleSelect}
>

View File

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

View File

@ -187,7 +187,7 @@ function AddAccessSubjectPopover({
<Button
variant="primary"
size="medium"
disabled={disabled || existingAccountIds === undefined}
disabled={disabled}
loading={!disabled && existingAccountIds === undefined}
>
<span className="i-ri-add-line size-3.5" aria-hidden />

View File

@ -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 = ({
<AlertDialogConfirmButton
tone="default"
loading={isExporting}
aria-labelledby={exportConfirmLabelId}
onClick={handleConfirmExport}
>
{isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: t(($) => $['operation.confirm'], { ns: 'common' })}
<span id={exportConfirmLabelId}>
{isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: t(($) => $['operation.confirm'], { ns: 'common' })}
</span>
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>

View File

@ -73,11 +73,7 @@ const BatchAction: FC<IBatchActionProps> = ({
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isDeleting}
disabled={isDeleting}
onClick={handleBatchDelete}
>
<AlertDialogConfirmButton loading={isDeleting} onClick={handleBatchDelete}>
{t(($) => $['operation.delete'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

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

View File

@ -113,10 +113,8 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
</div>
<div className="flex justify-end p-6 pt-5">
<div className="flex items-center gap-x-3">
<Button nativeButton={false} onClick={onClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button nativeButton={false} variant="primary" onClick={handlePublish}>
<Button onClick={onClose}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button>
<Button variant="primary" onClick={handlePublish}>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>

View File

@ -73,7 +73,6 @@ export function EnvironmentDeployMenu({
size="small"
variant="ghost"
loading={isRetrying}
disabled={isRetrying}
className="gap-1 px-2"
onClick={() => void refetchEnvironments()}
>

View File

@ -151,7 +151,6 @@ export const EnvironmentTable = memo(
size="small"
variant="secondary"
loading={latestVersionIsRetrying}
disabled={latestVersionIsRetrying}
onClick={() => void refetchLatestVersion()}
className="gap-1 px-2"
>

View File

@ -56,12 +56,7 @@ export function UndeployConfirmDialog({
<AlertDialogCancelButton variant="secondary" className="min-w-20" disabled={isPending}>
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
className="min-w-20"
disabled={isPending}
loading={isPending}
onClick={onConfirm}
>
<AlertDialogConfirmButton className="min-w-20" loading={isPending} onClick={onConfirm}>
{t(($) => $['deployTab.confirmUndeploy'])}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -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<HTMLFormElement> = useCallback(
(e) => {
@ -737,7 +738,7 @@ export function AppCardInteractions({
<AlertDialogConfirmButton
type="submit"
loading={isDeleting}
disabled={isDeleteConfirmDisabled}
disabled={deleteNameMismatch}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>

View File

@ -171,7 +171,7 @@ const ImportFromMarketplaceTemplateModal = ({
</Button>
<Button
variant="primary"
disabled={isLoading || isError || importing}
disabled={isLoading || isError}
loading={importing}
onClick={handleConfirm}
>

View File

@ -231,13 +231,7 @@ function AppIconPickerContent({
{t(($) => $['iconPicker.cancel'], { ns: 'app' })}
</Button>
<Button
variant="primary"
className="w-full"
disabled={uploading}
loading={uploading}
onClick={handleSelect}
>
<Button variant="primary" className="w-full" loading={uploading} onClick={handleSelect}>
{t(($) => $['iconPicker.ok'], { ns: 'app' })}
</Button>
</div>

View File

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

View File

@ -28,7 +28,7 @@ const Actions = ({ CustomActions }: ActionsProps) => {
return (
<Button
variant="primary"
disabled={isSubmitting || !canSubmit}
disabled={!canSubmit && !isSubmitting}
loading={isSubmitting}
onClick={() => form.handleSubmit()}
>

View File

@ -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(<UrlInput isRunning={true} onRun={mockOnRun} />)
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(<UrlInput isRunning={true} onRun={mockOnRun} />)
// 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(<UrlInput isRunning={true} onRun={mockOnRun} />)
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(<UrlInput isRunning={false} onRun={mockOnRun} />)
@ -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(<UrlInput isRunning={false} onRun={mockOnRun} />)
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(<UrlInput isRunning={true} onRun={mockOnRun} />)
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(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
expect(screen.getByRole('button')).toHaveAccessibleName(/run/i)
// Simulate finished state
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)

View File

@ -30,7 +30,7 @@ const UrlInput: FC<Props> = ({ isRunning, onRun }) => {
<div className="flex items-center justify-between gap-x-2">
<Input value={url} onChange={handleUrlChange} placeholder={docLink()} />
<Button variant="primary" onClick={handleOnRun} loading={isRunning}>
{!isRunning ? t(($) => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' }) : ''}
{t(($) => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' })}
</Button>
</div>
)

View File

@ -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 () => {

View File

@ -550,11 +550,7 @@ const Operations = ({
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleting}
disabled={deleting}
onClick={() => onOperate('delete')}
>
<AlertDialogConfirmButton loading={deleting} onClick={() => onOperate('delete')}>
{t(($) => $['operation.sure'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -142,6 +142,8 @@ const createDefaultProps = (overrides?: Partial<OptionsProps>): OptionsProps =>
...overrides,
})
const getRunButton = () => screen.getByRole('button', { name: /run/i })
describe('Options', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -169,7 +171,7 @@ describe('Options', () => {
render(<Options {...props} />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(getRunButton()).toBeInTheDocument()
expect(screen.getByText(/run/i)).toBeInTheDocument()
})
@ -260,7 +262,7 @@ describe('Options', () => {
render(<Options {...props} />)
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(<Options {...props} />)
expectLoadingButton(screen.getByRole('button'))
expectLoadingButton(getRunButton())
})
it('should enable button when step is finished', () => {
@ -276,7 +278,7 @@ describe('Options', () => {
render(<Options {...props} />)
expect(screen.getByRole('button')).not.toBeDisabled()
expect(getRunButton()).not.toBeDisabled()
})
})
@ -286,7 +288,7 @@ describe('Options', () => {
render(<Options {...props} />)
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(<Options {...props} />)
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(<Options {...props} />)
expectLoadingButton(screen.getByRole('button'))
expectLoadingButton(getRunButton())
})
it('should default runDisabled to undefined (falsy)', () => {
@ -311,7 +313,7 @@ describe('Options', () => {
render(<Options {...props} />)
expect(screen.getByRole('button')).not.toBeDisabled()
expect(getRunButton()).not.toBeDisabled()
})
})
@ -328,7 +330,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render(<Options {...props} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(getRunButton())
expect(mockOnSubmit).toHaveBeenCalled()
})
@ -347,7 +349,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render(<Options {...props} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(getRunButton())
expect(mockOnSubmit).not.toHaveBeenCalled()
})
@ -373,7 +375,7 @@ describe('Options', () => {
const props = createDefaultProps({ onSubmit: mockOnSubmit })
render(<Options {...props} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(getRunButton())
expect(mockOnSubmit).toHaveBeenCalledWith({ url: 'https://example.com', depth: 2 })
})
@ -489,7 +491,7 @@ describe('Options', () => {
render(<Options {...props} />)
// 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(<Options {...props} />)
const button = screen.getByRole('button')
const button = getRunButton()
expectLoadingButton(button)
expect(screen.getByText(/running/i)).toBeInTheDocument()
})
@ -525,7 +527,7 @@ describe('Options', () => {
render(<Options {...props} />)
// 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(<Options {...props} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(getRunButton())
expect(mockOnSubmit).toHaveBeenCalled()
})
@ -594,7 +596,7 @@ describe('Options', () => {
render(<Options {...props} />)
// 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(<Options {...props} />)
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(<Options {...props} />)
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(<Options {...props} />)
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(<Options {...props} />)
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(<Options {...props} />)
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(<Options {...props} />)
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(<Options {...props} />)
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(<Options {...props} />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(getRunButton())
expect(mockToastError).toHaveBeenCalledWith(expect.any(String))
})

View File

@ -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 (
<form
@ -87,12 +88,13 @@ const Options = ({ variables, step, runDisabled, onSubmit }: OptionsProps) => {
<Button
variant="primary"
onClick={form.handleSubmit}
disabled={runDisabled || isRunning}
disabled={runDisabled}
loading={isRunning}
aria-labelledby={runButtonLabelId}
className="shrink-0"
>
<RiPlayLargeLine className="size-4" />
<span>
<RiPlayLargeLine aria-hidden className="size-4" />
<span id={runButtonLabelId}>
{!isRunning
? t(($) => $[`${I18N_PREFIX}.run`], { ns: 'datasetCreation' })
: t(($) => $[`${I18N_PREFIX}.running`], { ns: 'datasetCreation' })}

View File

@ -156,11 +156,7 @@ const BatchAction: FC<IBatchActionProps> = ({
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isDeleting}
disabled={isDeleting}
onClick={handleBatchDelete}
>
<AlertDialogConfirmButton loading={isDeleting} onClick={handleBatchDelete}>
{t(($) => $['operation.sure'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -127,7 +127,7 @@ const Form = () => {
className="min-w-24"
variant="primary"
loading={loading}
disabled={loading || readonly}
disabled={readonly}
onClick={handleSave}
>
{t(($) => $['form.save'], { ns: 'datasetSettings' })}

View File

@ -10,7 +10,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useModalContext } from '@/context/modal-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
import { expectLoadingButton } from '@/test/button'
import { downloadUrl } from '@/utils/download'
import Compliance from '../compliance'
@ -144,6 +143,9 @@ describe('Compliance', () => {
// Assert
expect(screen.getAllByText('common.operation.download').length).toBeGreaterThan(0)
expect(getComplianceMenuItem('common.compliance.soc2Type1')).toHaveAccessibleName(
'common.compliance.soc2Type1 common.operation.download',
)
})
})
@ -229,7 +231,7 @@ describe('Compliance', () => {
expect(mockSetSettingsDestination).toHaveBeenCalledWith('billing')
})
// isPending branches: spinner visible, loading button contract, guard blocks second call
// isPending branches: spinner visible and the owning menu item blocks a second call
it('should show spinner and guard against duplicate download when isPending is true', async () => {
// Arrange
let resolveDownload: (value: { url: string }) => void
@ -253,13 +255,11 @@ describe('Compliance', () => {
expect(menuItem).not.toBeNull()
fireEvent.click(menuItem!)
// Assert - button should enter the loading-disabled state while mutation is pending
// Assert - the menu item owns the pending interaction state
await waitFor(
() => {
const loadingButton = menuItem!.querySelector('button[aria-disabled="true"]')
expect(loadingButton).not.toBeNull()
expectLoadingButton(loadingButton)
expect(loadingButton!.querySelector('.animate-spin')).not.toBeNull()
expect(menuItem).toHaveAttribute('aria-disabled', 'true')
expect(menuItem!.querySelector('.animate-spin')).not.toBeNull()
},
{ timeout: 10000 },
)
@ -297,9 +297,7 @@ describe('Compliance', () => {
// Wait for mutation to start and React to re-render (isPending=true)
await waitFor(
() => {
const loadingButton = menuItem!.querySelector('button[aria-disabled="true"]')
expect(loadingButton).not.toBeNull()
expectLoadingButton(loadingButton)
expect(menuItem).toHaveAttribute('aria-disabled', 'true')
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
},
{ timeout: 10000 },

View File

@ -1,6 +1,6 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { buttonVariants } from '@langgenius/dify-ui/button'
import {
DropdownMenuGroup,
DropdownMenuItem,
@ -54,18 +54,21 @@ function ComplianceDocActionVisual({
}: ComplianceDocActionVisualProps) {
if (isCurrentPlanCanDownload) {
return (
<Button
size="small"
disabled={isPending}
loading={isPending}
aria-hidden
className="pointer-events-none flex items-center"
>
<span className="i-ri-arrow-down-circle-line size-3.5 text-components-button-secondary-text-disabled" />
<span data-disabled={isPending || undefined} className={buttonVariants({ size: 'small' })}>
<span
aria-hidden
className="i-ri-arrow-down-circle-line size-3.5 text-components-button-secondary-text-disabled"
/>
<span className="system-xs-medium text-components-button-secondary-text">
{downloadText}
</span>
</Button>
{isPending && (
<span
className="i-ri-loader-2-line size-3 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
</span>
)
}
@ -154,6 +157,7 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
<DropdownMenuItem
className="h-10 justify-between py-1 pr-2 pl-1"
closeOnClick={!isCurrentPlanCanDownload}
disabled={isPending}
onClick={handleSelect}
>
{icon}

View File

@ -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()

View File

@ -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' })}
<span id={saveButtonLabelId}>
{t(($) => $[isSubmitting ? 'operation.saving' : 'operation.save'], {
ns: 'common',
})}
</span>
</Button>
</div>
</form>

View File

@ -167,13 +167,7 @@ function InviteForm({ isEmailSetup, onOpenChange, onSend }: InviteFormProps) {
{submissionError.message}
</div>
)}
<Button
type="submit"
variant="primary"
className="w-full"
loading={isPending}
disabled={isPending}
>
<Button type="submit" variant="primary" className="w-full" loading={isPending}>
{validRecipientCount > 0
? t(($) => $['members.sendInviteCount'], {
ns: 'common',

View File

@ -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(<ConfigModel {...props} loading />)
const action = screen.getByRole('button', { name })
expect(action).toHaveAttribute('aria-busy', 'true')
expect(action).toHaveAttribute('aria-disabled', 'true')
expect(action).not.toHaveAttribute('aria-busy')
})
})

View File

@ -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}
>
<RiScales3Line className="mr-0.5 size-3" />
<span aria-hidden className="i-ri-scales-3-line size-3" />
{t(($) => $['modelProvider.auth.authorizationError'], { ns: 'common' })}
<StatusDot status="warning" className="absolute -top-px -right-px size-1.5" />
</Button>
@ -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 && (
<>
<RiEqualizer2Line className="size-4" />
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t(($) => $['operation.config'], { ns: 'common' })}
</>
)}
{loadBalancingEnabled && !credentialRemoved && !loadBalancingInvalid && (
<>
<RiScales3Line className="size-4" />
<span aria-hidden className="i-ri-scales-3-line size-4" />
{t(($) => $['modelProvider.auth.configLoadBalancing'], { ns: 'common' })}
</>
)}

View File

@ -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(<MarketplaceSection {...defaultProps} />)
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(
<MarketplaceSection
{...defaultProps}
installingProvider={ModelProviderQuotaGetPaid.OPENAI}
/>,
)
expect(installButton).toHaveAccessibleName('plugin.installModal.installing OpenAI')
expect(installButton).toHaveAttribute('aria-disabled', 'true')
expect(installButton).not.toHaveAttribute('aria-busy')
expect(installButton).toHaveFocus()
})
})

View File

@ -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 (
<li
key={key}
@ -70,7 +72,7 @@ function MarketplaceSection({
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon aria-hidden="true" className="size-5 shrink-0 rounded-md" />
<span className="system-sm-regular text-text-secondary">
<span id={providerNameId} className="system-sm-regular text-text-secondary">
{modelNameMap[key]}
</span>
</div>
@ -78,24 +80,20 @@ function MarketplaceSection({
<Button
variant="secondary"
size="small"
aria-busy={isInstalling || undefined}
loading={isInstalling}
aria-labelledby={`${installButtonLabelId} ${providerNameId}`}
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling &&
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100',
)}
disabled={isInstalling}
onClick={() => onInstallPlugin(key)}
>
{isInstalling && (
<span
aria-hidden="true"
className="i-ri-loader-2-line size-3.5 animate-spin"
/>
)}
{isInstalling
? t(($) => $['installModal.installing'], { ns: 'plugin' })
: t(($) => $['modelProvider.selector.install'], { ns: 'common' })}
<span id={installButtonLabelId}>
{isInstalling
? t(($) => $['installModal.installing'], { ns: 'plugin' })
: t(($) => $['modelProvider.selector.install'], { ns: 'common' })}
</span>
</Button>
)}
</li>

View File

@ -269,7 +269,7 @@ function SummaryProviderCardActions({ summary, providerLabel, onUpdate }: Summar
<AlertDialogCancelButton disabled={deleting}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
<AlertDialogConfirmButton loading={deleting} onClick={handleDelete}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -62,7 +62,7 @@ export function CopyMembersConfirmDialog({
</Button>
<Button
variant="primary"
disabled={isActionDisabled}
disabled={isLoadingMemberCount}
loading={isCopyingRole}
onClick={() => onDuplicate(true)}
>

View File

@ -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()
})
})
})

View File

@ -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<WorkflowRunArchiveDownloadTaskResponse | null>(
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
)}
>
<div className="min-w-0 text-center">
<span className="truncate system-sm-semibold text-text-primary">{archiveMonth}</span>
<span id={archiveMonthLabelId} className="truncate system-sm-semibold text-text-primary">
{archiveMonth}
</span>
</div>
<div className="text-center system-sm-medium text-text-secondary tabular-nums">
{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 && (
<span className={cn(buttonIconClassName, 'size-3.5')} aria-hidden="true" />
)}
{buttonContent}
<span id={downloadActionLabelId}>{buttonContent}</span>
</Button>
}
/>

View File

@ -369,7 +369,6 @@ const WebAppsSectionContent = () => {
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={uninstallAppMutation.isPending}
disabled={uninstallAppMutation.isPending}
onClick={handleDelete}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}

View File

@ -42,6 +42,7 @@ const Loaded: React.FC<LoadedProps> = ({
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<LoadedProps> = ({
onClick={handleInstall}
disabled={isLoading}
loading={isInstalling}
aria-labelledby={installButtonLabelId}
>
<span>
<span id={installButtonLabelId}>
{t(($) => $[`${i18nPrefix}.${isInstalling ? 'installing' : 'install'}`], {
ns: 'plugin',
})}

View File

@ -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', {

View File

@ -70,13 +70,7 @@ const OAuthVisibilityDialog = ({
<Button disabled={loading} onClick={() => handleOpenChange(false)}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button
variant="primary"
className="ml-2"
loading={loading}
aria-busy={loading}
onClick={onConfirm}
>
<Button variant="primary" className="ml-2" loading={loading} onClick={onConfirm}>
{t(($) => $['auth.authorize'], { ns: 'plugin' })}
</Button>
</div>

View File

@ -88,7 +88,7 @@ const HeaderModals: FC<HeaderModalsProps> = ({
<AlertDialogCancelButton disabled={deleting}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={onDelete}>
<AlertDialogConfirmButton loading={deleting} onClick={onDelete}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -192,7 +192,7 @@ const Action: FC<Props> = ({
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleting} disabled={deleting} onClick={handleDelete}>
<AlertDialogConfirmButton loading={deleting} onClick={handleDelete}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -70,12 +70,7 @@ const PluginMutationModal: FC<Props> = ({
<div>{modalBottomLeft}</div>
<div className="ml-auto flex gap-2">
{!mutation.isPending && <Button onClick={onCancel}>{cancelButtonText}</Button>}
<Button
variant="primary"
loading={mutation.isPending}
onClick={mutate}
disabled={mutation.isPending}
>
<Button variant="primary" loading={mutation.isPending} onClick={mutate}>
{confirmButtonText}
</Button>
</div>

View File

@ -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<string>(originalPackageInfo.payload.icon)
useEffect(() => {
@ -72,13 +73,11 @@ const UpdatePluginModal = ({
const [uploadStep, setUploadStep] = useState<UploadStep>(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}
<span id={upgradeButtonLabelId}>{configBtnText}</span>
</Button>
</div>
</>

View File

@ -135,7 +135,7 @@ const Conversion = () => {
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isPending}
disabled={isPending || !canConvertDataset}
disabled={!canConvertDataset}
onClick={handleConvert}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}

View File

@ -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(
<Actions
formParams={createFormParams({ canSubmit: false, isSubmitting: true })}
onBack={vi.fn()}
/>,
)
expectLoadingButton(
screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i }),
)
})
})

View File

@ -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 (
<div className="flex items-center justify-end gap-x-2 p-4 pt-2">
@ -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' })}
</Button>

View File

@ -87,7 +87,7 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) =
<div className="flex items-center justify-end gap-2 self-stretch pt-5">
<Button onClick={onCancel}>{t(($) => $['newApp.Cancel'], { ns: 'app' })}</Button>
<Button
disabled={!currentFile || loading}
disabled={!currentFile}
variant="primary"
tone="destructive"
onClick={handleImport}

View File

@ -23,12 +23,7 @@ const PublishAction = ({
const { t } = useTranslation('snippet')
return (
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing || !canSave}
onClick={onPublish}
>
<Button variant="primary" loading={isPublishing} disabled={!canSave} onClick={onPublish}>
{t(($) => $.publishButton)}
</Button>
)

View File

@ -174,7 +174,7 @@ export function CreateSnippetDialog({
</Button>
<Button
variant="primary"
disabled={!name.trim() || isSubmitting}
disabled={!name.trim()}
loading={isSubmitting}
onClick={handleConfirm}
>

View File

@ -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())

View File

@ -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}`))

View File

@ -68,7 +68,7 @@ const GetSchema: FC<Props> = ({ onChange }) => {
onClick={handleImportFromUrl}
loading={isParsing}
>
{isParsing ? '' : t(($) => $['operation.ok'], { ns: 'common' })}
{t(($) => $['operation.ok'], { ns: 'common' })}
</Button>
</div>
</DropdownMenuContent>

View File

@ -170,7 +170,6 @@ const TestApi: FC<Props> = ({ positionCenter, customCollection, tool, onHide })
variant="primary"
className="mt-4 h-10 w-full"
loading={testing}
disabled={testing}
onClick={handleTest}
>
{t(($) => $['test.title'], { ns: 'tools' })}

View File

@ -215,11 +215,7 @@ const MCPList = ({
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isDeleting}
disabled={isDeleting}
onClick={handleDeleteConfirm}
>
<AlertDialogConfirmButton loading={isDeleting} onClick={handleDeleteConfirm}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -161,7 +161,6 @@ const ConfigCredential: FC<Props> = ({
{!readonly && (
<Button
loading={isLoading || isSaving}
disabled={isLoading || isSaving}
variant="primary"
onClick={handleSave}
>

View File

@ -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()
})

View File

@ -33,6 +33,7 @@ export const DSLExportConfirmContent = ({
const [exportSecrets, setExportSecrets] = useState<boolean>(false)
const [isExporting, setIsExporting] = useState(false)
const exportButtonLabelId = React.useId()
const submit = useCallback(async () => {
if (isExporting) return
@ -131,14 +132,16 @@ export const DSLExportConfirmContent = ({
<AlertDialogConfirmButton
tone="default"
loading={isExporting}
disabled={isExporting}
aria-labelledby={exportButtonLabelId}
onClick={submit}
>
{isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: exportSecrets
? t(($) => $['env.export.export'], { ns: 'workflow' })
: t(($) => $['env.export.ignore'], { ns: 'workflow' })}
<span id={exportButtonLabelId}>
{isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: exportSecrets
? t(($) => $['env.export.export'], { ns: 'workflow' })
: t(($) => $['env.export.ignore'], { ns: 'workflow' })}
</span>
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>

View File

@ -94,7 +94,7 @@ const BeforeRunForm: FC<CustomRunFormProps> = (props) => {
onClick={handleRunWithSyncDraft}
variant="primary"
loading={isPending}
disabled={isPending || startRunBtnDisabled}
disabled={startRunBtnDisabled}
>
{t(($) => $['singleRun.startRun'], { ns: 'workflow' })}
</Button>

View File

@ -476,7 +476,7 @@ const EmailSenderContent = ({
)}
<div className="mt-6 flex flex-row-reverse gap-2">
<Button
disabled={sendingEmail || !confirmChecked}
disabled={!confirmChecked}
loading={sendingEmail}
variant="primary"
onClick={handleConfirm}

View File

@ -48,17 +48,10 @@ const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton
nativeButton={false}
variant="secondary"
closeProps={{ nativeButton: false }}
>
<AlertDialogCancelButton variant="secondary">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
nativeButton={false}
onClick={onDelete.bind(null, versionInfo.id)}
>
<AlertDialogConfirmButton onClick={onDelete.bind(null, versionInfo.id)}>
{t(($) => $['operation.delete'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -20,7 +20,7 @@ const Empty: FC<EmptyProps> = ({ onResetFilter }) => {
{t(($) => $['versionHistory.filter.empty'], { ns: 'workflow' })}
</div>
<div className="flex justify-center">
<Button nativeButton={false} size="small" onClick={onResetFilter}>
<Button size="small" onClick={onResetFilter}>
{t(($) => $['versionHistory.filter.reset'], { ns: 'workflow' })}
</Button>
</div>

View File

@ -48,18 +48,10 @@ const RestoreConfirmModal: FC<RestoreConfirmModalProps> = ({
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton
nativeButton={false}
variant="secondary"
closeProps={{ nativeButton: false }}
>
<AlertDialogCancelButton variant="secondary">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
nativeButton={false}
tone="default"
onClick={onRestore.bind(null, versionInfo)}
>
<AlertDialogConfirmButton tone="default" onClick={onRestore.bind(null, versionInfo)}>
{t(($) => $['common.restore'], { ns: 'workflow' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -234,7 +234,7 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) =
<div className="flex items-center justify-end gap-2 self-stretch pt-5">
<Button onClick={onCancel}>{t(($) => $['newApp.Cancel'], { ns: 'app' })}</Button>
<Button
disabled={!currentFile || loading}
disabled={!currentFile}
variant="primary"
tone="destructive"
onClick={handleImport}

View File

@ -71,7 +71,7 @@ export function AgentConfigureClearSessionConfirmDialog({
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={confirmDisabled || isConfirming}
disabled={confirmDisabled}
loading={isConfirming}
onClick={handleConfirm}
>

View File

@ -35,7 +35,7 @@ export function AgentBuildDraftBar({
const collapsedBarRef = useRef<HTMLDivElement>(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,

View File

@ -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 (
<div className="flex w-full min-w-0 items-center justify-between gap-2 p-2 group-data-open/publish-bar:justify-end group-data-open/publish-bar:px-4 group-data-open/publish-bar:pt-2 group-data-open/publish-bar:pb-4">
@ -402,13 +401,16 @@ function PublishBarActions({
<Button
type="button"
variant="primary"
disabled={!canPublish}
disabled={!publishIsAvailable}
loading={isPublishing}
aria-labelledby={publishButtonLabelId}
className="h-8 gap-1 rounded-lg px-3"
onClick={onPublishRequest}
>
{actionIcon && <span aria-hidden className={`${actionIcon} size-4 shrink-0`} />}
<span className="shrink-0">{actionLabel}</span>
<span id={publishButtonLabelId} className="shrink-0">
{actionLabel}
</span>
{showShortcut && <PublishShortcut />}
</Button>
</div>

View File

@ -182,6 +182,14 @@ const connection = (
version,
})
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<ReturnType<typeof connection>>()
clientMock.createConnection.mockReturnValue(createConnectionDeferred.promise)
render(<AddSourcePage knowledgeSpaceId="space-1" />)
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<ReturnType<typeof connection>>()
queryState.connections.data = { pages: [{ items: [connection('error')] }] }
clientMock.refreshConnection.mockReturnValue(refreshConnectionDeferred.promise)
render(<AddSourcePage knowledgeSpaceId="space-1" />)
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')] }] }

View File

@ -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(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
expect(retryButton).toHaveAccessibleName('dataset.newKnowledge.loadMoreRevisions')
expect(retryButton).toHaveAttribute('aria-disabled', 'true')
revisionsQuery.isFetchingNextPage = false
revisionsQuery.hasNextPage = false
rendered.rerender(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
@ -763,13 +766,21 @@ describe('DocumentDetailPage', () => {
chunksQuery.hasNextPage = true
chunksQuery.isFetchNextPageError = true
render(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
const rendered = render(
<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />,
)
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(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
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<void>((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(<DocumentDetailPage documentId="document-1" knowledgeSpaceId="space-1" />)
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()
})

View File

@ -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(<DocumentsPage knowledgeSpaceId="space-1" />)
@ -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 () => {

View File

@ -97,6 +97,14 @@ const run = (state: string, overrides: Partial<SourceWorkflowRun> = {}): SourceW
...overrides,
})
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<SourceWorkflowRun>()
clientMock.getRun.mockResolvedValue(run('running', { progressCompleted: 1 }))
clientMock.cancel.mockResolvedValue(run('canceled', { progressCompleted: 1 }))
clientMock.cancel.mockReturnValue(cancelDeferred.promise)
render(<WebsiteCrawlPreview connection={connection} knowledgeSpaceId="space-1" />)
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',

View File

@ -283,6 +283,7 @@ function ConnectionForm({
provider: Provider
}) {
const { t } = useTranslation('dataset')
const connectButtonLabelId = useId()
const supportedAuthKinds = getSupportedAuthKinds(provider)
const [authKind, setAuthKind] = useState<ConnectionAuthKind>(supportedAuthKinds[0] ?? 'api-key')
const [configuration, setConfiguration] = useState<Record<string, string>>({})
@ -413,10 +414,18 @@ function ConnectionForm({
{t(($) => $['newKnowledge.connectionFailed'])}
</p>
)}
<Button type="submit" variant="primary" className="mt-4" disabled={pending}>
{pending
? t(($) => $['newKnowledge.connectingProvider'])
: t(($) => $['newKnowledge.connectProvider'])}
<Button
type="submit"
variant="primary"
className="mt-4"
loading={pending}
aria-labelledby={connectButtonLabelId}
>
<span id={connectButtonLabelId}>
{pending
? t(($) => $['newKnowledge.connectingProvider'])
: t(($) => $['newKnowledge.connectProvider'])}
</span>
</Button>
</form>
)
@ -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'])}
</p>
)}
<Button className="mt-4" onClick={() => void refresh()} disabled={pending}>
{pending
? t(($) => $['newKnowledge.refreshingConnection'])
: tCommon(($) => $['operation.retry'])}
<Button
className="mt-4"
onClick={() => void refresh()}
loading={pending}
aria-labelledby={refreshButtonLabelId}
>
<span id={refreshButtonLabelId}>
{pending
? t(($) => $['newKnowledge.refreshingConnection'])
: tCommon(($) => $['operation.retry'])}
</span>
</Button>
</div>
)
@ -570,7 +587,7 @@ function ProvisioningConnection({
{t(($) => $['newKnowledge.connectionRefreshFailed'])}
</p>
)}
<Button className="mt-3" loading={pending} onClick={() => void refresh()} disabled={pending}>
<Button className="mt-3" loading={pending} onClick={() => void refresh()}>
{t(($) => $['newKnowledge.refreshConnectionStatus'])}
</Button>
</div>

View File

@ -55,7 +55,7 @@ export function AddSourceExitDialog({
<AlertDialogCancelButton variant="secondary" disabled={discarding}>
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={discarding} disabled={discarding} onClick={onConfirm}>
<AlertDialogConfirmButton loading={discarding} onClick={onConfirm}>
{t(($) => $['newKnowledge.discardDraftConfirm'])}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -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({
<Button
type="submit"
variant="primary"
disabled={!canSubmit || formBusy || workflowUncertain}
disabled={!canSubmit || busy || workflowUncertain}
loading={submitting}
aria-describedby={!selectedPageIds.size ? 'add-source-selection-requirement' : undefined}
>

View File

@ -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<Set<string>>(() => new Set())
const [focusedChunkId, setFocusedChunkId] = useState<string>()
const [treeHasFocus, setTreeHasFocus] = useState(false)
@ -264,13 +265,15 @@ export function DocumentChunkTreePanel({
</p>
)}
<Button
disabled={isFetchingNextPage}
loading={isFetchingNextPage}
aria-labelledby={loadMoreLabelId}
onClick={handleLoadMore}
>
{isFetchNextPageError
? tCommon(($) => $['operation.retry'])
: t(($) => $['newKnowledge.loadMore'])}
<span id={loadMoreLabelId}>
{isFetchNextPageError
? tCommon(($) => $['operation.retry'])
: t(($) => $['newKnowledge.loadMore'])}
</span>
</Button>
</div>
)}

View File

@ -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<Exclude<LogicalDocumentRevision, null>>
taskIsActive: boolean
titleRef: RefObject<HTMLHeadingElement | null>
}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
const revisionTriggerRef = useRef<HTMLButtonElement>(null)
const loadMoreRevisionLabelId = useId()
const loadMoreRequestedRef = useRef(false)
const wasFetchingNextPageRef = useRef(false)
useEffect(() => {
@ -114,20 +113,21 @@ export function DocumentDetailHeader({
)}
{(hasNextRevisionPage || isFetchNextRevisionPageError) && (
<Button
disabled={isFetchingNextRevisionPage}
loading={isFetchingNextRevisionPage}
aria-labelledby={loadMoreRevisionLabelId}
onClick={() => {
loadMoreRequestedRef.current = true
fetchNextRevisionPage()
}}
>
{isFetchNextRevisionPageError
? tCommon(($) => $['operation.retry'])
: t(($) => $['newKnowledge.loadMoreRevisions'])}
<span id={loadMoreRevisionLabelId}>
{isFetchNextRevisionPageError
? tCommon(($) => $['operation.retry'])
: t(($) => $['newKnowledge.loadMoreRevisions'])}
</span>
</Button>
)}
<Button
aria-busy={reindexing || taskIsActive}
aria-describedby={reindexDisabledReasonId}
disabled={reindexDisabled}
loading={reindexing}

View File

@ -202,8 +202,6 @@ export function DocumentDetailPage({
onRevisionChange={(revision) => 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 && (

View File

@ -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<HTMLButtonElement>(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<unknown>,
) => {
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({
<span>{t(($) => $['newKnowledge.documentPermissionRestricted'])}</span>
<Button
ref={permissionRetryRef}
disabled={permissionRecoveryBusy}
loading={permissionRecoveryBusy}
onClick={() =>
void retryWritePermission().then((recovered) => {
@ -145,7 +164,7 @@ export function DocumentDetailStatus({
</div>
)}
{submissionTimedOut && (
{(submissionTimedOut || pendingRecoveryAction) && (
<div
className="mt-4 flex flex-wrap items-center justify-between gap-2 rounded-lg bg-state-warning-hover px-3 py-2 system-xs-regular text-text-warning"
role="alert"
@ -153,19 +172,16 @@ export function DocumentDetailStatus({
<span>{t(($) => $['newKnowledge.documentReindexConfirmationDelayed'])}</span>
<div className="flex flex-wrap gap-2">
<Button
disabled={submissionRecoveryBusy}
loading={submissionRecoveryBusy}
onClick={() =>
void recheckTimedOutSubmission().finally(() => titleRef.current?.focus())
}
loading={pendingRecoveryAction === 'check'}
disabled={recoveryBusy && pendingRecoveryAction !== 'check'}
onClick={() => void handleSubmissionRecovery('check', recheckTimedOutSubmission)}
>
{t(($) => $['newKnowledge.checkReindexStatus'])}
</Button>
<Button
disabled={submissionRecoveryBusy}
onClick={() =>
void retryTimedOutSubmission().finally(() => titleRef.current?.focus())
}
loading={pendingRecoveryAction === 'retry'}
disabled={recoveryBusy && pendingRecoveryAction !== 'retry'}
onClick={() => void handleSubmissionRecovery('retry', retryTimedOutSubmission)}
>
{t(($) => $['newKnowledge.retryReindexDocument'])}
</Button>

View File

@ -275,7 +275,6 @@ export function DocumentsEmpty({
<Button
className="mt-4"
variant="primary"
aria-busy={uploading}
disabled={!canEdit}
loading={uploading}
aria-describedby={!canEdit ? readOnlyReasonId : undefined}
@ -454,7 +453,6 @@ export function DocumentsList({
</Button>
<Button
variant="primary"
aria-busy={uploading}
disabled={!canEdit}
loading={uploading}
aria-describedby={!canEdit ? readOnlyReasonId : undefined}
@ -593,7 +591,6 @@ export function DocumentsList({
<Button
ref={loadMoreButtonRef}
aria-label={`${tCommon(($) => $['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({
<div className="mt-5 flex justify-center">
<Button
ref={loadMoreButtonRef}
aria-busy={isFetchingNextPage}
loading={isFetchingNextPage}
onBlur={(event) => {
if (event.relatedTarget) restoreLoadMoreFocusRef.current = false
@ -659,7 +655,6 @@ export function DocumentBulkActions({
>
<Button
aria-describedby={disabled ? 'document-reindex-unavailable' : undefined}
aria-busy={reindexing}
className="shrink-0"
disabled={disabled}
loading={reindexing}

View File

@ -1938,7 +1938,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
<Button
ref={permissionRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.permissionLoadFailed'])}`}
aria-busy={workspacePermissionKeysFetching}
loading={workspacePermissionKeysFetching}
size="small"
onBlur={(event) => {
@ -1972,7 +1971,6 @@ export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }
<Button
ref={documentsRetryButtonRef}
aria-label={`${tCommon(($) => $['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 }
<Button
ref={documentsRetryButtonRef}
aria-label={`${tCommon(($) => $['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) => {

View File

@ -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({
)}
</p>
<div className="mt-5 flex gap-2">
<Button render={<Link href={newKnowledgeListPath} />}>
<Link href={newKnowledgeListPath} className={buttonVariants()}>
{t(($) => $['newKnowledge.backToList'])}
</Button>
</Link>
{!notFound && (
<Button variant="primary" onClick={() => void knowledgeSpaceQuery.refetch()}>
{tCommon(($) => $['operation.retry'])}

View File

@ -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({
</div>
{canCreate && (
<div className="flex items-center gap-1">
<Button
render={<Link href="/datasets/new/create" />}
variant="primary"
size="medium"
className="px-2 shadow-xs"
<Link
href="/datasets/new/create"
className={buttonVariants({
variant: 'primary',
size: 'medium',
className: 'px-2 shadow-xs',
})}
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
<span>{createLabel}</span>
</Button>
</Link>
</div>
)}
</div>

View File

@ -428,7 +428,6 @@ export function ProcessingTasksDrawer({
<Button
ref={permissionQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.permissionLoadFailed'])}`}
aria-busy={permissionQueryFetching}
className="mt-3"
loading={permissionQueryFetching}
size="small"
@ -452,7 +451,6 @@ export function ProcessingTasksDrawer({
<Button
ref={taskQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.tasksErrorDescription'])}`}
aria-busy={taskQueryFetching}
className="mt-3"
loading={taskQueryFetching}
size="small"
@ -476,7 +474,6 @@ export function ProcessingTasksDrawer({
<Button
ref={documentQueryRetryButtonRef}
aria-label={`${tCommon(($) => $['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({
<div className="mt-4 flex justify-center">
<Button
ref={loadMoreButtonRef}
aria-busy={isFetchingNextTaskPage || isFetchingNextDocumentPage}
loading={isFetchingNextTaskPage || isFetchingNextDocumentPage}
onBlur={() => {
loadMoreRequestedRef.current = false

View File

@ -192,7 +192,6 @@ function SourceActions({
<AlertDialogConfirmButton
tone="destructive"
loading={pendingAction === 'remove'}
disabled={pendingAction === 'remove'}
onClick={() =>
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'])}

View File

@ -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}
<span id={primaryActionLabelId}>{primaryLabel}</span>
</Button>
)}
</form>
@ -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'])}
<span id={stopButtonLabelId}>
{stopping
? t(($) => $['newKnowledge.stoppingCrawl'])
: t(($) => $['newKnowledge.stopCrawl'])}
</span>
</Button>
</div>
{requestError === 'CANCEL_FAILED' && (
@ -1333,11 +1342,7 @@ export function WebsiteCrawlPreview({
<AlertDialogCancelButton disabled={discarding}>
{t(($) => $['newKnowledge.keepEditing'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={discarding}
loading={discarding}
onClick={() => void discardAndCancel()}
>
<AlertDialogConfirmButton loading={discarding} onClick={() => void discardAndCancel()}>
{t(($) => $['newKnowledge.discardSourceChangesConfirm'])}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -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({
<AlertDialogConfirmButton
tone="destructive"
loading={deleteMutation.isPending}
disabled={isDeleteDisabled}
disabled={isDeleteUnavailable}
onClick={handleDelete}
>
{tCommon(($) => (referenceCount > 0 ? $['operation.confirm'] : $['operation.delete']))}

View File

@ -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({
<AlertDialogConfirmButton
tone="destructive"
loading={deleteMutation.isPending}
disabled={isDeleteDisabled}
disabled={isDeleteUnavailable}
onClick={handleDelete}
>
{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 (
<div className="flex min-w-0 items-center gap-2">
<SkillTagFilter onOpenTagManagement={onOpenTagManagement} />
@ -692,7 +689,7 @@ function SkillsToolbar({
{canEdit && (
<Button
className="h-8 gap-1 px-3"
disabled={isMutating}
disabled={creating}
loading={importing}
onClick={onImport}
>
@ -704,7 +701,7 @@ function SkillsToolbar({
<Button
variant="primary"
className="h-8 gap-0.5 px-3"
disabled={isMutating}
disabled={importing}
loading={creating}
onClick={onCreate}
>
@ -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(
{