mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
fix(button): align loading accessibility contract (#41823)
This commit is contained in:
parent
2972468bc1
commit
1b4ae3bcd2
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -90,7 +90,6 @@ export default function CheckCode() {
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleGetEMailVerificationCode}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -89,7 +89,7 @@ export default function MailAndCodeAuth() {
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || !email}
|
||||
disabled={!email}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -73,7 +73,6 @@ export function EnvironmentDeployMenu({
|
||||
size="small"
|
||||
variant="ghost"
|
||||
loading={isRetrying}
|
||||
disabled={isRetrying}
|
||||
className="gap-1 px-2"
|
||||
onClick={() => void refetchEnvironments()}
|
||||
>
|
||||
|
||||
@ -151,7 +151,6 @@ export const EnvironmentTable = memo(
|
||||
size="small"
|
||||
variant="secondary"
|
||||
loading={latestVersionIsRetrying}
|
||||
disabled={latestVersionIsRetrying}
|
||||
onClick={() => void refetchLatestVersion()}
|
||||
className="gap-1 px-2"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -171,7 +171,7 @@ const ImportFromMarketplaceTemplateModal = ({
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isLoading || isError || importing}
|
||||
disabled={isLoading || isError}
|
||||
loading={importing}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -28,7 +28,7 @@ const Actions = ({ CustomActions }: ActionsProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
disabled={!canSubmit && !isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={() => form.handleSubmit()}
|
||||
>
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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))
|
||||
})
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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' })}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -62,7 +62,7 @@ export function CopyMembersConfirmDialog({
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isActionDisabled}
|
||||
disabled={isLoadingMemberCount}
|
||||
loading={isCopyingRole}
|
||||
onClick={() => onDuplicate(true)}
|
||||
>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -369,7 +369,6 @@ const WebAppsSectionContent = () => {
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={uninstallAppMutation.isPending}
|
||||
disabled={uninstallAppMutation.isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
|
||||
@ -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',
|
||||
})}
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -135,7 +135,7 @@ const Conversion = () => {
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={isPending}
|
||||
disabled={isPending || !canConvertDataset}
|
||||
disabled={!canConvertDataset}
|
||||
onClick={handleConvert}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
|
||||
@ -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 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -174,7 +174,7 @@ export function CreateSnippetDialog({
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
disabled={!name.trim()}
|
||||
loading={isSubmitting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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}`))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -161,7 +161,6 @@ const ConfigCredential: FC<Props> = ({
|
||||
{!readonly && (
|
||||
<Button
|
||||
loading={isLoading || isSaving}
|
||||
disabled={isLoading || isSaving}
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -71,7 +71,7 @@ export function AgentConfigureClearSessionConfirmDialog({
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
disabled={confirmDisabled || isConfirming}
|
||||
disabled={confirmDisabled}
|
||||
loading={isConfirming}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')] }] }
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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'])}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'])}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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']))}
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user