Compare commits

...

49 Commits

Author SHA1 Message Date
yyh
f36245ee35 fix(web): preserve image preview sessions through dialog exit 2026-10-07 12:30:35 +08:00
yyh
1559298336 fix(web): preserve log inspector dialog sessions 2026-10-07 12:30:35 +08:00
yyh
dd9605b33b refactor(web): preserve annotation quota notice lifecycle 2026-10-07 12:30:35 +08:00
yyh
ec9969c3e6 refactor(web): preserve context feature dialog sessions 2026-10-07 12:30:35 +08:00
yyh
399c478dd9 refactor: preserve input configuration dialog sessions 2026-10-07 12:30:34 +08:00
yyh
10131b005b refactor: scope prompt and code generator drafts to dialog sessions 2026-10-07 12:30:34 +08:00
yyh
596658fcd6 refactor(web): preserve member role dialog sessions through exit 2026-10-07 12:30:34 +08:00
yyh
4c0ed90a43 test(web): keep metadata dialog consumers real in existing suites 2026-10-07 12:30:34 +08:00
yyh
669759eed7 refactor(web): scope knowledge metadata drafts to dialog sessions 2026-10-07 12:30:34 +08:00
yyh
7f6966524a fix(web): isolate knowledge configuration dialog sessions 2026-10-07 12:30:33 +08:00
yyh
71a13d2f80 fix(web): preserve MCP provider dialog sessions 2026-10-07 12:30:33 +08:00
yyh
03782a5b7d fix(web): preserve MCP server configuration dialog sessions 2026-10-07 12:30:33 +08:00
yyh
dd3f90a727 fix(web): preserve API key dialog sessions and return focus 2026-10-07 12:30:33 +08:00
yyh
31fad26a29 fix(web): retain app creation and editing drafts until owner success 2026-10-07 12:30:33 +08:00
yyh
58cad24590 ci: retry unavailable CodeQL analysis after GitHub rate limit 2026-10-07 12:30:33 +08:00
yyh
79df3b5ab5 fix(web): preserve app menu dialog sessions until submission succeeds 2026-10-07 12:30:32 +08:00
yyh
1ec48df6e2 refactor(web): align workspace member dialog lifecycles 2026-10-07 12:30:32 +08:00
yyh
17e820ddaf fix(web): scope human input email dialog sessions 2026-10-07 12:30:32 +08:00
yyh
5258ca8814 refactor(web): preserve plugin reference dialog lifecycles 2026-10-07 12:30:32 +08:00
yyh
6aafcf2f66 refactor(web): simplify Web App menu dialogs 2026-10-07 12:30:32 +08:00
yyh
763d4a3070 fix(web): preserve first-use DSL dialog activation 2026-10-07 12:30:31 +08:00
yyh
303f6c5a5c refactor(web): scope workflow import dialogs to popup sessions 2026-10-07 12:30:31 +08:00
yyh
7bc72e53e2 test: await gallery focus restoration after Tab 2026-10-07 12:30:31 +08:00
yyh
16b9586abc fix(web): preserve gallery preload and popup resource lifetime 2026-10-07 12:30:31 +08:00
yyh
82cec802fe fix(web): keep thumbnail file type private 2026-10-07 12:30:31 +08:00
yyh
01a9274491 refactor(web): keep image triggers inside their dialog root 2026-10-07 12:30:31 +08:00
yyh
c1ae342517 refactor(web): own dataset image preview sessions 2026-10-07 12:30:31 +08:00
yyh
270f41a5b1 refactor(web): own parameter schema preview dialogs 2026-10-07 12:30:31 +08:00
yyh
5a36b938d1 refactor(web): own retrieval detail preview sessions 2026-10-07 12:30:31 +08:00
yyh
e0f132dc8a fix(web): close settings after successful site update 2026-10-07 12:30:30 +08:00
yyh
5ae6e037b5 chore(web): remove unused settings notification copy 2026-10-07 12:30:30 +08:00
yyh
ae83debcd4 refactor(web): own website settings dialog sessions 2026-10-07 12:30:30 +08:00
yyh
f3db46943a test(web): verify confirmed tool icon payloads 2026-10-07 12:30:30 +08:00
yyh
dcb40f809a fix(web): keep icon picker dismissal available in every tab 2026-10-07 12:30:30 +08:00
yyh
cc3e3ff4ae refactor(web): rely on keyboard scopes instead of picker open observers 2026-10-07 12:30:30 +08:00
yyh
3b6532c26b refactor(web): colocate icon picker entries and editing sessions 2026-10-07 12:30:30 +08:00
yyh
2cd1d49652 refactor(web): scope website embedding previews to their dialog 2026-10-07 12:30:30 +08:00
yyh
b5fa1120d8 refactor(web): own attachment preview resources in dialog sessions 2026-10-07 12:30:30 +08:00
yyh
9db88d7be0 refactor(web): scope chat feedback drafts and submission results 2026-10-07 12:30:29 +08:00
yyh
bc03de3879 refactor(web): keep workflow editor drafts inside dialog popups 2026-10-07 12:30:29 +08:00
yyh
aeabc62704 refactor(web): scope auto-update settings drafts to the popup 2026-10-07 12:30:29 +08:00
yyh
8731e9531e refactor(web): keep invitation requests in the dialog owner 2026-10-07 12:30:29 +08:00
yyh
a439622bde refactor(web): remove obsolete profile update service 2026-10-07 12:30:29 +08:00
yyh
5af2696fce refactor(web): scope account editing sessions to dialog popups 2026-10-07 12:30:29 +08:00
yyh
b40af0240d test(web): exercise the real parameter dialog from the node panel 2026-10-07 12:30:28 +08:00
yyh
bf0e797c89 refactor(web): scope configuration drafts to dialog popups 2026-10-07 12:30:28 +08:00
yyh
c6c5039f23 refactor(web): keep the customize dialog as a complete feature scope 2026-10-07 12:30:28 +08:00
yyh
eb987d0c81 refactor(web): let local preview dialogs own their open state 2026-10-07 12:30:28 +08:00
yyh
37c550095c feat(ui): expose imperative actions types for public ref contracts 2026-10-07 12:30:14 +08:00
518 changed files with 24992 additions and 15430 deletions

View File

@@ -88,7 +88,7 @@
},
"web/app/components/app/annotation/__tests__/index.spec.tsx": {
"typescript/no-explicit-any": {
"count": 7
"count": 6
}
},
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
@@ -218,11 +218,6 @@
"count": 2
}
},
"web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/app/configuration/config-prompt/message-type-selector.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
@@ -263,7 +258,7 @@
},
"web/app/components/app/configuration/config-var/config-modal/index.tsx": {
"typescript/no-explicit-any": {
"count": 4
"count": 3
}
},
"web/app/components/app/configuration/config-var/index.tsx": {
@@ -276,11 +271,6 @@
"count": 1
}
},
"web/app/components/app/configuration/config/__tests__/agent-setting-button.spec.tsx": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/app/configuration/config/__tests__/config-audio.spec.tsx": {
"typescript/no-explicit-any": {
"count": 1
@@ -361,16 +351,6 @@
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/params-config/index.tsx": {
"eslint-react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/app/configuration/dataset-config/settings-modal/index.tsx": {
"eslint-react/set-state-in-effect": {
"count": 2
@@ -513,10 +493,10 @@
},
"web/app/components/app/log/var-panel.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
"count": 1
}
},
"web/app/components/app/overview/__tests__/app-chart-utils.spec.ts": {
@@ -766,7 +746,7 @@
},
"web/app/components/base/chat/chat/type.ts": {
"typescript/no-explicit-any": {
"count": 5
"count": 4
}
},
"web/app/components/base/chat/chat/utils.ts": {
@@ -885,25 +865,17 @@
"count": 1
}
},
"web/app/components/base/file-uploader/audio-preview.tsx": {
"jsx-a11y/media-has-caption": {
"count": 1
}
},
"web/app/components/base/file-uploader/file-list-in-log.tsx": {
"eslint-react/no-missing-key": {
"count": 1
}
},
"web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
"web/app/components/base/file-uploader/file-preview-dialog.tsx": {
"jsx-a11y/media-has-caption": {
"count": 2
}
},
"web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx": {
"web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
@@ -939,11 +911,6 @@
"count": 3
}
},
"web/app/components/base/file-uploader/video-preview.tsx": {
"jsx-a11y/media-has-caption": {
"count": 1
}
},
"web/app/components/base/form/components/base/base-form.tsx": {
"typescript/no-explicit-any": {
"count": 6
@@ -1128,11 +1095,6 @@
"count": 4
}
},
"web/app/components/base/message-log-modal/index.stories.tsx": {
"no-console": {
"count": 1
}
},
"web/app/components/base/new-audio-button/index.tsx": {
"typescript/no-explicit-any": {
"count": 1
@@ -1440,14 +1402,6 @@
"count": 1
}
},
"web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/image-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-input.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@@ -1456,14 +1410,6 @@
"count": 1
}
},
"web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/image-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/datasets/common/image-uploader/store.tsx": {
"react/only-export-components": {
"count": 3
@@ -1827,20 +1773,12 @@
"count": 1
}
},
"web/app/components/datasets/hit-testing/components/result-item-external.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/hit-testing/components/result-item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
"count": 1
}
},
"web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx": {
@@ -2433,11 +2371,6 @@
"count": 1
}
},
"web/app/components/tools/mcp/mcp-server-param-item.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/tools/setting/build-in/config-credentials.tsx": {
"typescript/no-explicit-any": {
"count": 3
@@ -2448,11 +2381,6 @@
"count": 4
}
},
"web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx": {
"eslint-react/static-components": {
"count": 2
}
},
"web/app/components/workflow-app/components/workflow-children.tsx": {
"typescript/no-explicit-any": {
"count": 2
@@ -3026,14 +2954,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/http/panel.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/workflow/nodes/http/use-config.ts": {
"eslint-react/set-state-in-effect": {
"count": 1
@@ -3241,11 +3161,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/llm/components/structure-output.tsx": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/workflow/nodes/llm/use-config.ts": {
"eslint-react/set-state-in-effect": {
"count": 1
@@ -3315,19 +3230,6 @@
"count": 3
}
},
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/parameter-extractor/use-config.ts": {
"eslint-react/set-state-in-effect": {
"count": 2
@@ -3950,12 +3852,6 @@
}
},
"web/app/components/workflow/variable-inspect/right.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 3
}
@@ -4270,7 +4166,7 @@
"count": 1
},
"typescript/no-explicit-any": {
"count": 5
"count": 4
}
},
"web/service/datasets.ts": {

View File

@@ -62,8 +62,8 @@ A type is not public merely because Base UI names it or an implementation once e
addition to matching component props, export a type only when it pairs with a public factory or a
real consumer must name it independently.
State, event details and reasons, actions, controlled-state helpers, context values, render
helpers, styling helpers, and upstream passthrough aliases are private by default. Public props
State, event details and reasons, controlled-state helpers, context values, render helpers,
styling helpers, and upstream passthrough aliases are private by default. Public props
already provide contextual typing for inline render and event callbacks.
Preserve upstream `className` and `style` callbacks. Resolve `className` with the owning Base UI

View File

@@ -6,6 +6,7 @@ import {
AlertDialogCancelButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
} from '../index'
@@ -93,7 +94,9 @@ describe('AlertDialog wrapper', () => {
<AlertDialog defaultOpen>
<AlertDialogContent>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogCancelButton>Cancel</AlertDialogCancelButton>
<AlertDialogFooter>
<AlertDialogCancelButton>Cancel</AlertDialogCancelButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>,
)

View File

@@ -0,0 +1,12 @@
import type { AlertDialogActions, AlertDialogProps } from '@langgenius/dify-ui/alert-dialog'
import { AlertDialog } from '@langgenius/dify-ui/alert-dialog'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function alertDialogActionsTypeContract() {
const actionsRef = React.createRef<AlertDialogActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<AlertDialogProps['actionsRef']>['current']
>()
return <AlertDialog actionsRef={actionsRef} />
}

View File

@@ -3,11 +3,11 @@ import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
} from '.'
@@ -24,7 +24,7 @@ const meta = {
docs: {
description: {
component:
'Compound alert dialog built on Base UI AlertDialog. Use it for destructive or high-risk confirmations that require an explicit user decision. Compose title, description, and actions via `AlertDialogActions`, `AlertDialogCancelButton`, and `AlertDialogConfirmButton`.',
'Compound alert dialog built on Base UI AlertDialog. Use it for destructive or high-risk confirmations that require an explicit user decision. Compose title, description, and actions via `AlertDialogFooter`, `AlertDialogCancelButton`, and `AlertDialogConfirmButton`.',
},
},
},
@@ -50,10 +50,10 @@ export const Default: Story = {
removed.
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton variant="secondary">Cancel</AlertDialogCancelButton>
<AlertDialogConfirmButton>Delete</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
@@ -95,10 +95,10 @@ export const NonDestructive: Story = {
history.
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton variant="secondary">Not now</AlertDialogCancelButton>
<AlertDialogConfirmButton tone="default">Publish</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
),
@@ -128,7 +128,7 @@ function ControlledDemo() {
token afterwards.
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton variant="secondary">Keep token</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
@@ -138,7 +138,7 @@ function ControlledDemo() {
>
Revoke
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
@@ -177,7 +177,7 @@ function LoadingConfirmDemo() {
finalize.
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton variant="secondary" disabled={pending}>
Cancel
</AlertDialogCancelButton>
@@ -189,7 +189,7 @@ function LoadingConfirmDemo() {
>
<span id={confirmLabelId}>{pending ? 'Archiving…' : 'Archive'}</span>
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)

View File

@@ -13,6 +13,8 @@ const AlertDialogTrigger = BaseAlertDialog.Trigger
const AlertDialogTitle = BaseAlertDialog.Title
const AlertDialogDescription = BaseAlertDialog.Description
type AlertDialogActions = BaseAlertDialog.Root.Actions
type AlertDialogProps<Payload = unknown> = BaseAlertDialog.Root.Props<Payload>
type AlertDialogTriggerProps<Payload = unknown> = BaseAlertDialog.Trigger.Props<Payload>
type AlertDialogTitleProps = BaseAlertDialog.Title.Props
@@ -59,9 +61,9 @@ function AlertDialogContent({
)
}
type AlertDialogActionsProps = React.ComponentProps<'div'>
type AlertDialogFooterProps = React.ComponentProps<'div'>
function AlertDialogActions({ className, ...props }: AlertDialogActionsProps) {
function AlertDialogFooter({ className, ...props }: AlertDialogFooterProps) {
return (
<div
className={cn('flex items-start justify-end gap-2 self-stretch p-6', className)}
@@ -90,21 +92,22 @@ function AlertDialogConfirmButton({
export {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
}
export type {
AlertDialogActionsProps,
AlertDialogActions,
AlertDialogCancelButtonProps,
AlertDialogConfirmButtonProps,
AlertDialogContentProps,
AlertDialogDescriptionProps,
AlertDialogFooterProps,
AlertDialogProps,
AlertDialogTitleProps,
AlertDialogTriggerProps,

View File

@@ -0,0 +1,12 @@
import type { AutocompleteActions, AutocompleteProps } from '@langgenius/dify-ui/autocomplete'
import { Autocomplete } from '@langgenius/dify-ui/autocomplete'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function autocompleteActionsTypeContract() {
const actionsRef = React.createRef<AutocompleteActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<AutocompleteProps<string>['actionsRef']>['current']
>()
return <Autocomplete actionsRef={actionsRef} />
}

View File

@@ -16,6 +16,8 @@ import {
} from '../overlay-shared'
import { parsePlacement } from '../placement'
type AutocompleteActions = BaseAutocomplete.Root.Actions
type AutocompleteProps<ItemValue> = BaseAutocomplete.Root.Props<ItemValue>
type AutocompleteChangeEventDetails = BaseAutocomplete.Root.ChangeEventDetails
type AutocompleteGroupedProps<Items extends readonly { items: readonly unknown[] }[]> = Omit<
@@ -428,6 +430,7 @@ export {
}
export type {
AutocompleteActions,
AutocompleteChangeEventDetails,
AutocompleteClearProps,
AutocompleteCollectionProps,

View File

@@ -0,0 +1,12 @@
import type { ComboboxActions, ComboboxProps } from '@langgenius/dify-ui/combobox'
import { Combobox } from '@langgenius/dify-ui/combobox'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function comboboxActionsTypeContract() {
const actionsRef = React.createRef<ComboboxActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<ComboboxProps<string>['actionsRef']>['current']
>()
return <Combobox actionsRef={actionsRef} />
}

View File

@@ -16,6 +16,8 @@ import {
} from '../overlay-shared'
import { parsePlacement } from '../placement'
type ComboboxActions = BaseCombobox.Root.Actions
type ComboboxProps<
Value,
Multiple extends boolean | undefined = false,
@@ -565,6 +567,7 @@ export {
}
export type {
ComboboxActions,
ComboboxChangeEventDetails,
ComboboxChipProps,
ComboboxChipRemoveProps,

View File

@@ -1,7 +1,12 @@
'use client'
import type { IconButtonProps } from '../../icon-button'
import type { PopoverContentProps, PopoverProps, PopoverTriggerProps } from '../../popover'
import type {
PopoverActions,
PopoverContentProps,
PopoverProps,
PopoverTriggerProps,
} from '../../popover'
import { useMergedRefs } from '@base-ui/utils/useMergedRefs'
import * as React from 'react'
import { cn } from '../../cn'
@@ -73,7 +78,7 @@ function PickerRoot({
const [labelId, setLabelId] = React.useState<string>()
const triggerRef = React.useRef<HTMLButtonElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const actionsRef = React.useRef<NonNullable<PopoverProps['actionsRef']>['current']>(null)
const actionsRef = React.useRef<PopoverActions>(null)
const [validationAttempted, setValidationAttempted] = React.useState(false)
const errorMessage = props.invalid
? (props.validationMessage ?? 'Choose a valid value.')

View File

@@ -0,0 +1,12 @@
import type { DialogActions, DialogProps } from '@langgenius/dify-ui/dialog'
import { Dialog } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function dialogActionsTypeContract() {
const actionsRef = React.createRef<DialogActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<DialogProps['actionsRef']>['current']
>()
return <Dialog actionsRef={actionsRef} />
}

View File

@@ -14,6 +14,8 @@ const DialogPortal = BaseDialog.Portal
const DialogClose = BaseDialog.Close
const createDialogHandle = BaseDialog.createHandle
type DialogActions = BaseDialog.Root.Actions
type DialogProps<Payload = unknown> = BaseDialog.Root.Props<Payload>
type DialogHandle<Payload = unknown> = BaseDialog.Handle<Payload>
type DialogTriggerProps<Payload = unknown> = BaseDialog.Trigger.Props<Payload>
@@ -100,6 +102,7 @@ export {
}
export type {
DialogActions,
DialogBackdropProps,
DialogCloseProps,
DialogContentProps,

View File

@@ -0,0 +1,12 @@
import type { DrawerActions, DrawerProps } from '@langgenius/dify-ui/drawer'
import { Drawer } from '@langgenius/dify-ui/drawer'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function drawerActionsTypeContract() {
const actionsRef = React.createRef<DrawerActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<DrawerProps['actionsRef']>['current']
>()
return <Drawer actionsRef={actionsRef} />
}

View File

@@ -16,6 +16,8 @@ const DrawerDescription = BaseDrawer.Description
const DrawerClose = BaseDrawer.Close
const createDrawerHandle = BaseDrawer.createHandle
type DrawerActions = BaseDrawer.Root.Actions
type DrawerProps<Payload = unknown> = BaseDrawer.Root.Props<Payload>
type DrawerHandle<Payload = unknown> = BaseDrawer.Handle<Payload>
type DrawerProviderProps = BaseDrawer.Provider.Props
@@ -121,6 +123,7 @@ export {
}
export type {
DrawerActions,
DrawerBackdropProps,
DrawerCloseProps,
DrawerContentProps,

View File

@@ -1,9 +1,14 @@
import type { DropdownMenuHandle } from '@langgenius/dify-ui/dropdown-menu'
import type {
DropdownMenuActions,
DropdownMenuHandle,
DropdownMenuProps,
} from '@langgenius/dify-ui/dropdown-menu'
import {
createDropdownMenuHandle,
DropdownMenu,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
type Payload = { documentId: string }
@@ -32,3 +37,11 @@ export function dropdownMenuTypeContracts() {
)
return { trigger, menu, invalid }
}
export function dropdownMenuActionsTypeContract() {
const actionsRef = React.createRef<DropdownMenuActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<DropdownMenuProps['actionsRef']>['current']
>()
return <DropdownMenu actionsRef={actionsRef} />
}

View File

@@ -27,6 +27,8 @@ const createDropdownMenuHandle = Menu.createHandle
type DropdownMenuHandle<Payload = unknown> = Menu.Handle<Payload>
type DropdownMenuActions = Menu.Root.Actions
type DropdownMenuProps<Payload = unknown> = Menu.Root.Props<Payload>
type DropdownMenuTriggerProps<Payload = unknown> = Menu.Trigger.Props<Payload>
type DropdownMenuPortalProps = Menu.Portal.Props
@@ -319,6 +321,7 @@ export {
}
export type {
DropdownMenuActions,
DropdownMenuCheckboxItemIndicatorProps,
DropdownMenuCheckboxItemProps,
DropdownMenuContentProps,

View File

@@ -0,0 +1,10 @@
import type { FieldActions, FieldProps } from '@langgenius/dify-ui/field'
import { Field } from '@langgenius/dify-ui/field'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function fieldActionsTypeContract() {
const actionsRef = React.createRef<FieldActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<NonNullable<FieldProps['actionsRef']>['current']>()
return <Field actionsRef={actionsRef} />
}

View File

@@ -6,6 +6,8 @@ import { cn } from '../cn'
import { formLabelClassName } from '../form-control-shared'
import { resolveClassName } from '../internals/resolve-class-name'
type FieldActions = BaseFieldNS.Root.Actions
type FieldProps = BaseFieldNS.Root.Props
function Field({ className, ...props }: FieldProps) {
@@ -74,6 +76,7 @@ const FieldValidity = BaseField.Validity
export { Field, FieldDescription, FieldError, FieldItem, FieldLabel, FieldValidity }
export type {
FieldActions,
FieldDescriptionProps,
FieldErrorProps,
FieldItemProps,

View File

@@ -1,5 +1,7 @@
import type { InfotipActions, InfotipProps } from '@langgenius/dify-ui/infotip'
import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function infotipTypeContracts(
triggerRef: React.Ref<HTMLButtonElement>,
@@ -45,3 +47,11 @@ export function infotipTypeContracts(
)
return { composition, labelledTrigger, unnamed, children, render, state }
}
export function infotipActionsTypeContract() {
const actionsRef = React.createRef<InfotipActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<InfotipProps['actionsRef']>['current']
>()
return <Infotip actionsRef={actionsRef} />
}

View File

@@ -23,6 +23,8 @@ const iconSizeClassNames = {
const Infotip = BasePopover.Root
const InfotipTitle = BasePopover.Title
type InfotipActions = BasePopover.Root.Actions
type InfotipProps<Payload = unknown> = BasePopover.Root.Props<Payload>
type InfotipTitleProps = BasePopover.Title.Props
@@ -106,4 +108,10 @@ function InfotipContent({
}
export { Infotip, InfotipContent, InfotipTitle, InfotipTrigger }
export type { InfotipContentProps, InfotipProps, InfotipTitleProps, InfotipTriggerProps }
export type {
InfotipActions,
InfotipContentProps,
InfotipProps,
InfotipTitleProps,
InfotipTriggerProps,
}

View File

@@ -0,0 +1,12 @@
import type { PopoverActions, PopoverProps } from '@langgenius/dify-ui/popover'
import { Popover } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function popoverActionsTypeContract() {
const actionsRef = React.createRef<PopoverActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<PopoverProps['actionsRef']>['current']
>()
return <Popover actionsRef={actionsRef} />
}

View File

@@ -17,6 +17,8 @@ const PopoverTitle = BasePopover.Title
const PopoverDescription = BasePopover.Description
const createPopoverHandle = BasePopover.createHandle
type PopoverActions = BasePopover.Root.Actions
type PopoverProps<Payload = unknown> = BasePopover.Root.Props<Payload>
type PopoverArrowProps = BasePopover.Arrow.Props
type PopoverPortalProps = BasePopover.Portal.Props
@@ -126,6 +128,7 @@ export {
PopoverTrigger,
}
export type {
PopoverActions,
PopoverArrowProps,
PopoverBackdropProps,
PopoverCloseProps,

View File

@@ -0,0 +1,12 @@
import type { PreviewCardActions, PreviewCardProps } from '@langgenius/dify-ui/preview-card'
import { PreviewCard } from '@langgenius/dify-ui/preview-card'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function previewCardActionsTypeContract() {
const actionsRef = React.createRef<PreviewCardActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<PreviewCardProps['actionsRef']>['current']
>()
return <PreviewCard actionsRef={actionsRef} />
}

View File

@@ -26,6 +26,8 @@ const PreviewCardTrigger = BasePreviewCard.Trigger
const PreviewCardViewport = BasePreviewCard.Viewport
const createPreviewCardHandle = BasePreviewCard.createHandle
type PreviewCardActions = BasePreviewCard.Root.Actions
type PreviewCardProps<Payload = unknown> = BasePreviewCard.Root.Props<Payload>
type PreviewCardHandle<Payload = unknown> = BasePreviewCard.Handle<Payload>
type PreviewCardTriggerProps<Payload = unknown> = BasePreviewCard.Trigger.Props<Payload>
@@ -115,6 +117,7 @@ export {
PreviewCardViewport,
}
export type {
PreviewCardActions,
PreviewCardContentProps,
PreviewCardHandle,
PreviewCardPopupProps,

View File

@@ -0,0 +1,12 @@
import type { SelectActions, SelectProps } from '@langgenius/dify-ui/select'
import { Select } from '@langgenius/dify-ui/select'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function selectActionsTypeContract() {
const actionsRef = React.createRef<SelectActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<SelectProps<string>['actionsRef']>['current']
>()
return <Select actionsRef={actionsRef} />
}

View File

@@ -17,6 +17,8 @@ import {
} from '../overlay-shared'
import { parsePlacement } from '../placement'
type SelectActions = BaseSelect.Root.Actions
type SelectProps<Value, Multiple extends boolean | undefined = false> = BaseSelect.Root.Props<
Value,
Multiple
@@ -287,6 +289,7 @@ export {
}
export type {
SelectActions,
SelectContentProps,
SelectGroupLabelProps,
SelectGroupProps,

View File

@@ -0,0 +1,12 @@
import type { TooltipActions, TooltipProps } from '@langgenius/dify-ui/tooltip'
import { Tooltip } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
import { expectTypeOf } from 'vite-plus/test'
export function tooltipActionsTypeContract() {
const actionsRef = React.createRef<TooltipActions>()
expectTypeOf(actionsRef.current).toEqualTypeOf<
NonNullable<TooltipProps['actionsRef']>['current']
>()
return <Tooltip actionsRef={actionsRef} />
}

View File

@@ -13,6 +13,8 @@ const Tooltip = BaseTooltip.Root
const TooltipTrigger = BaseTooltip.Trigger
type TooltipProviderProps = BaseTooltip.Provider.Props
type TooltipActions = BaseTooltip.Root.Actions
type TooltipProps<Payload = unknown> = BaseTooltip.Root.Props<Payload>
type TooltipTriggerProps<Payload = unknown> = BaseTooltip.Trigger.Props<Payload>
@@ -54,4 +56,10 @@ function TooltipContent({
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
export type { TooltipContentProps, TooltipProps, TooltipProviderProps, TooltipTriggerProps }
export type {
TooltipActions,
TooltipContentProps,
TooltipProps,
TooltipProviderProps,
TooltipTriggerProps,
}

View File

@@ -11,7 +11,7 @@ import dayjs from 'dayjs'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import * as React from 'react'
import AnnotationFull from '@/app/components/billing/annotation-full'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { AnnotationFullModal } from '@/app/components/billing/annotation-full/modal'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import Billing from '@/app/components/billing/billing-page'
import { NUM_INFINITE } from '@/app/components/billing/config'
@@ -575,7 +575,12 @@ describe('Capacity Full Components Integration', () => {
annotation_quota_limit: { size: 10, limit: 10 },
})
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
render(<AnnotationFullModal open onOpenChange={vi.fn()} />)
expect(
screen.getByRole('dialog', {
name: /annotatedResponse\.fullTipLine1.*annotatedResponse\.fullTipLine2/,
}),
).toBeInTheDocument()
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
@@ -588,7 +593,7 @@ describe('Capacity Full Components Integration', () => {
annotation_quota_limit: { size: 10, limit: 10 },
})
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
render(<AnnotationFullModal open={false} onOpenChange={vi.fn()} />)
expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument()
})
@@ -860,7 +865,7 @@ describe('Cross-Component Upgrade Flow', () => {
annotation_quota_limit: { size: 10, limit: 10 },
})
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
render(<AnnotationFullModal open onOpenChange={vi.fn()} />)
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)

View File

@@ -14,11 +14,11 @@ import type {
} from './type'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -855,14 +855,14 @@ const ProviderConfigModal: FC<Props> = ({
{t(($) => $[`${I18N_PREFIX}.removeConfirmContent`], { ns: 'app' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleRemove}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}

View File

@@ -1,5 +1,5 @@
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import { screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
@@ -30,7 +30,7 @@ const createAccountResponse = (name = 'Alice'): GetAccountProfileResponse => ({
timezone: 'UTC',
})
const renderPage = () => {
const renderPage = ({ isPasswordSet = false, passwordLoginEnabled = true } = {}) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(
consoleQuery.apps.get.queryOptions({
@@ -41,14 +41,23 @@ const renderPage = () => {
return renderWithConsoleQuery(<AccountPage />, {
queryClient,
accountProfile: createAccountResponse(),
accountProfile: { ...createAccountResponse(), is_password_set: isPasswordSet },
systemFeatures: { enable_email_password_login: passwordLoginEnabled },
features: { education: { enabled: false } },
})
}
function pendingResponse() {
let resolve!: (response: Response) => void
const promise = new Promise<Response>((resolveResponse) => {
resolve = resolveResponse
})
return { promise, resolve }
}
describe('AccountPage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetAllMocks()
mocks.get.mockImplementation(
async () =>
new Response(JSON.stringify(createAccountResponse('Alice Cooper')), {
@@ -80,5 +89,273 @@ describe('AccountPage', () => {
const request = mocks.request.mock.calls[0]?.[2]?.request as Request
expect(request.method).toBe('PATCH')
await expect(request.json()).resolves.toEqual({ name: 'Alice Cooper' })
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getAllByText('Alice Cooper')).toHaveLength(2)
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
expect(screen.getByRole('textbox', { name: 'accountSettings.account.name' })).toHaveValue(
'Alice Cooper',
)
})
it.each(['cancel', 'escape', 'backdrop'])(
'discards a name draft after closing with %s',
async (dismissal) => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
const nameInput = screen.getByRole('textbox', { name: 'accountSettings.account.name' })
await user.clear(nameInput)
await user.type(nameInput, 'Unsaved name')
if (dismissal === 'cancel')
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
else if (dismissal === 'escape') await user.keyboard('{Escape}')
else await user.click(document.body)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
expect(screen.getByRole('textbox', { name: 'accountSettings.account.name' })).toHaveValue(
'Alice',
)
expect(mocks.request).not.toHaveBeenCalled()
},
)
it('keeps the name draft after failure and allows retry through Enter', async () => {
const user = userEvent.setup()
mocks.request.mockRejectedValueOnce(new Error('Name update failed'))
renderPage()
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
const nameInput = screen.getByRole('textbox', { name: 'accountSettings.account.name' })
await user.clear(nameInput)
await user.type(nameInput, 'Alice Cooper{Enter}')
await waitFor(() =>
expect(screen.getByRole('button', { name: 'common.operation.save' })).not.toHaveAttribute(
'aria-disabled',
'true',
),
)
expect(
screen.getByRole('dialog', { name: 'accountSettings.account.editName' }),
).toBeInTheDocument()
expect(nameInput).toHaveValue('Alice Cooper')
expect(mocks.request).toHaveBeenCalledOnce()
await user.click(nameInput)
await user.keyboard('{Enter}')
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(mocks.request).toHaveBeenCalledTimes(2)
})
it('keeps name editing open until both saving and profile refresh finish', async () => {
const user = userEvent.setup()
const saving = pendingResponse()
const refreshing = pendingResponse()
mocks.request.mockReturnValueOnce(saving.promise)
mocks.get.mockReturnValueOnce(refreshing.promise)
renderPage()
await user.click(screen.getByRole('button', { name: 'common.operation.edit' }))
await user.type(
screen.getByRole('textbox', { name: 'accountSettings.account.name' }),
' Cooper',
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
const dialog = screen.getByRole('dialog', { name: 'accountSettings.account.editName' })
const cancel = within(dialog).getByRole('button', { name: 'common.operation.cancel' })
expect(cancel).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toHaveAttribute(
'aria-disabled',
'true',
)
await user.click(cancel)
await user.keyboard('{Escape}')
await user.click(document.body)
expect(dialog).toBeInTheDocument()
expect(mocks.request).toHaveBeenCalledOnce()
await act(async () =>
saving.resolve(new Response(JSON.stringify(createAccountResponse('Alice Cooper')))),
)
await waitFor(() => expect(mocks.get).toHaveBeenCalled())
expect(cancel).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toHaveAttribute(
'aria-disabled',
'true',
)
await user.keyboard('{Escape}')
expect(dialog).toBeInTheDocument()
await act(async () =>
refreshing.resolve(new Response(JSON.stringify(createAccountResponse('Alice Cooper')))),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getAllByText('Alice Cooper')).toHaveLength(2)
})
it.each([false, true])(
'starts a fresh password session when isPasswordSet=%s',
async (isPasswordSet) => {
const user = userEvent.setup()
renderPage({ isPasswordSet })
const triggerName = isPasswordSet
? 'accountSettings.account.resetPassword'
: 'accountSettings.account.setPassword'
const passwordName = isPasswordSet
? 'accountSettings.account.newPassword'
: 'accountSettings.account.password'
await user.click(screen.getByRole('button', { name: triggerName }))
if (isPasswordSet) {
const current = screen.getByLabelText('accountSettings.account.currentPassword')
expect(current).toHaveAttribute('autocomplete', 'current-password')
await user.type(current, 'Current123')
} else {
expect(
screen.queryByLabelText('accountSettings.account.currentPassword'),
).not.toBeInTheDocument()
}
const password = screen.getByLabelText(passwordName)
const confirm = screen.getByLabelText('accountSettings.account.confirmPassword')
expect(password).toHaveAttribute('autocomplete', 'new-password')
expect(confirm).toHaveAttribute('autocomplete', 'new-password')
await user.type(password, 'NewPassword123')
await user.type(confirm, 'NewPassword123')
for (const toggle of screen.getAllByRole('button', { name: 'login.showPassword' })) {
await user.click(toggle)
}
expect(password).toHaveAttribute('type', 'text')
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: triggerName }))
expect(screen.getByLabelText(passwordName)).toHaveValue('')
expect(screen.getByLabelText(passwordName)).toHaveAttribute('type', 'password')
expect(screen.getByLabelText('accountSettings.account.confirmPassword')).toHaveValue('')
expect(screen.getByLabelText('accountSettings.account.confirmPassword')).toHaveAttribute(
'type',
'password',
)
if (isPasswordSet) {
expect(screen.getByLabelText('accountSettings.account.currentPassword')).toHaveValue('')
expect(screen.getByLabelText('accountSettings.account.currentPassword')).toHaveAttribute(
'type',
'password',
)
}
expect(mocks.request).not.toHaveBeenCalled()
},
)
it('keeps all password values after a failed reset and retries the generated endpoint', async () => {
const user = userEvent.setup()
mocks.request.mockRejectedValueOnce(new Error('Password update failed'))
mocks.get.mockResolvedValue(
new Response(JSON.stringify({ ...createAccountResponse(), is_password_set: true })),
)
renderPage({ isPasswordSet: true })
await user.click(screen.getByRole('button', { name: 'accountSettings.account.resetPassword' }))
await user.type(screen.getByLabelText('accountSettings.account.currentPassword'), 'Current123')
await user.type(screen.getByLabelText('accountSettings.account.newPassword'), 'NewPassword123')
await user.type(
screen.getByLabelText('accountSettings.account.confirmPassword'),
'NewPassword123{Enter}',
)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'common.operation.reset' })).not.toHaveAttribute(
'aria-disabled',
'true',
),
)
expect(screen.getByLabelText('accountSettings.account.currentPassword')).toHaveValue(
'Current123',
)
expect(screen.getByLabelText('accountSettings.account.newPassword')).toHaveValue(
'NewPassword123',
)
expect(screen.getByLabelText('accountSettings.account.confirmPassword')).toHaveValue(
'NewPassword123',
)
expect(mocks.request).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'common.operation.reset' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(mocks.request).toHaveBeenCalledTimes(2)
const request = mocks.request.mock.calls[1]?.[2]?.request as Request
expect(request.method).toBe('POST')
expect(request.url).toContain('/account/password')
await expect(request.json()).resolves.toEqual({
password: 'Current123',
new_password: 'NewPassword123',
repeat_new_password: 'NewPassword123',
})
})
it('keeps password setting open until the refreshed profile confirms the new password', async () => {
const user = userEvent.setup()
const saving = pendingResponse()
const refreshing = pendingResponse()
mocks.request.mockReturnValueOnce(saving.promise)
mocks.get.mockReturnValueOnce(refreshing.promise)
renderPage()
await user.click(screen.getByRole('button', { name: 'accountSettings.account.setPassword' }))
await user.type(screen.getByLabelText('accountSettings.account.password'), 'NewPassword123')
await user.type(
screen.getByLabelText('accountSettings.account.confirmPassword'),
'NewPassword123{Enter}',
)
const dialog = screen.getByRole('dialog', { name: 'accountSettings.account.setPassword' })
const cancel = within(dialog).getByRole('button', { name: 'common.operation.cancel' })
expect(cancel).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toHaveAttribute(
'aria-disabled',
'true',
)
await user.click(cancel)
await user.keyboard('{Escape}')
await user.click(document.body)
expect(dialog).toBeInTheDocument()
expect(mocks.request).toHaveBeenCalledOnce()
await act(async () =>
saving.resolve(
new Response(JSON.stringify({ ...createAccountResponse(), is_password_set: true })),
),
)
await waitFor(() => expect(mocks.get).toHaveBeenCalled())
expect(cancel).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toHaveAttribute(
'aria-disabled',
'true',
)
await user.keyboard('{Escape}')
expect(dialog).toBeInTheDocument()
await act(async () =>
refreshing.resolve(
new Response(JSON.stringify({ ...createAccountResponse(), is_password_set: true })),
),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(
screen.getByRole('button', { name: 'accountSettings.account.resetPassword' }),
).toBeInTheDocument()
const request = mocks.request.mock.calls[0]?.[2]?.request as Request
await expect(request.json()).resolves.toEqual({
password: '',
new_password: 'NewPassword123',
repeat_new_password: 'NewPassword123',
})
})
it('does not offer password editing when password login is disabled', () => {
renderPage({ passwordLoginEnabled: false })
expect(
screen.queryByRole('button', { name: 'accountSettings.account.setPassword' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'accountSettings.account.resetPassword' }),
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,87 @@
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import { QueryClientProvider } from '@tanstack/react-query'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { createTestQueryClient } from '@/test/query-client'
import { ChangePasswordDialog } from '../change-password-dialog'
const mocks = vi.hoisted(() => ({ request: vi.fn() }))
vi.mock('@/service/base', () => ({
del: vi.fn(),
get: vi.fn(),
patch: vi.fn(),
post: vi.fn(),
request: mocks.request,
sseGeneratorPost: vi.fn(),
}))
function pendingResponse() {
let resolve!: (response: Response) => void
let reject!: (error: Error) => void
const promise = new Promise<Response>((resolveResponse, rejectResponse) => {
resolve = resolveResponse
reject = rejectResponse
})
return { promise, resolve, reject }
}
it('keeps native submit focus during password saving and supports retry without leaving the dialog', async () => {
// Chromium proves native Enter submission and focus preservation when pending controls change.
const firstSave = pendingResponse()
const retry = pendingResponse()
mocks.request.mockReset()
mocks.request.mockReturnValueOnce(firstSave.promise).mockReturnValueOnce(retry.promise)
const screen = await render(
<QueryClientProvider client={createTestQueryClient()}>
<ChangePasswordDialog isPasswordSet />
</QueryClientProvider>,
)
const trigger = screen.getByRole('button', { name: 'accountSettings.account.resetPassword' })
await trigger.click()
const dialog = screen.getByRole('dialog', { name: 'accountSettings.account.resetPassword' })
await dialog.getByLabelText('accountSettings.account.currentPassword').fill('Current123')
await dialog.getByLabelText('accountSettings.account.newPassword').fill('NewPassword123')
const confirmPassword = dialog.getByLabelText('accountSettings.account.confirmPassword')
await confirmPassword.fill('NewPassword123')
await userEvent.keyboard('{Enter}')
const save = dialog.getByRole('button', { name: 'common.operation.reset' })
await expect.element(save).toHaveAttribute('aria-disabled', 'true')
await expect.element(confirmPassword).toHaveFocus()
await expect.element(confirmPassword).toHaveAttribute('readonly')
await userEvent.keyboard('{Escape}')
await expect.element(dialog).toBeVisible()
await expect.element(confirmPassword).toHaveFocus()
expect(mocks.request).toHaveBeenCalledOnce()
firstSave.reject(new Error('Password update failed'))
await expect.element(save).not.toHaveAttribute('aria-disabled', 'true')
await expect.element(confirmPassword).not.toHaveAttribute('readonly')
await expect.element(confirmPassword).toHaveFocus()
await expect.element(confirmPassword).toHaveValue('NewPassword123')
await save.click()
await expect.element(save).toHaveAttribute('aria-disabled', 'true')
await expect.element(save).toHaveFocus()
await userEvent.keyboard('{Escape}')
await expect.element(dialog).toBeVisible()
await expect.element(save).toHaveFocus()
expect(mocks.request).toHaveBeenCalledTimes(2)
retry.resolve(
new Response(
JSON.stringify({
id: 'user-id',
name: 'Alice',
email: 'alice@example.com',
avatar: '',
avatar_url: null,
is_password_set: true,
timezone: 'UTC',
} satisfies GetAccountProfileResponse),
{ headers: { 'content-type': 'application/json' } },
),
)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
})

View File

@@ -0,0 +1,211 @@
'use client'
import type { AccountPasswordPayload } from '@dify/contracts/api/console/account/types.gen'
import type { DialogActions } from '@langgenius/dify-ui/dialog'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/notifications'
import { validPassword } from '@/config'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { consoleQuery } from '@/service/console'
type ChangePasswordFormProps = {
isPasswordSet: boolean
isPending: boolean
onSave: (password: AccountPasswordPayload) => Promise<void>
}
function ChangePasswordForm({ isPasswordSet, isPending, onSave }: ChangePasswordFormProps) {
const { t } = useTranslation(['common', 'login', 'accountSettings'])
const [currentPassword, setCurrentPassword] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const handleSubmit = () => {
if (isPending) return
if (!password.trim()) {
toast.error(t(($) => $['error.passwordEmpty'], { ns: 'login' }))
return
}
if (!validPassword.test(password)) {
toast.error(t(($) => $['error.passwordInvalid'], { ns: 'login' }))
return
}
if (password !== confirmPassword) {
toast.error(t(($) => $['account.notEqual'], { ns: 'accountSettings' }))
return
}
void onSave({
password: currentPassword,
new_password: password,
repeat_new_password: confirmPassword,
})
}
return (
<form
onSubmit={(event) => {
event.preventDefault()
handleSubmit()
}}
>
<DialogTitle className="mb-6 title-2xl-semi-bold text-text-primary">
{isPasswordSet
? t(($) => $['account.resetPassword'], { ns: 'accountSettings' })
: t(($) => $['account.setPassword'], { ns: 'accountSettings' })}
</DialogTitle>
{isPasswordSet && (
<Field name="current-password">
<FieldLabel className="system-sm-semibold">
{t(($) => $['account.currentPassword'], { ns: 'accountSettings' })}
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onValueChange={setCurrentPassword}
autoComplete="current-password"
spellCheck={false}
readOnly={isPending}
/>
<InputGroupAddon align="inline-end">
<IconButton
size="lg"
aria-label={t(($) => $[showCurrentPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
<span aria-hidden="true">{showCurrentPassword ? '👀' : '😝'}</span>
</IconButton>
</InputGroupAddon>
</InputGroup>
</Field>
)}
<Field name="new-password" className="mt-8">
<FieldLabel className="system-sm-semibold">
{isPasswordSet
? t(($) => $['account.newPassword'], { ns: 'accountSettings' })
: t(($) => $['account.password'], { ns: 'accountSettings' })}
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showPassword ? 'text' : 'password'}
value={password}
onValueChange={setPassword}
autoComplete="new-password"
spellCheck={false}
readOnly={isPending}
/>
<InputGroupAddon align="inline-end">
<IconButton
size="lg"
aria-label={t(($) => $[showPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
onClick={() => setShowPassword(!showPassword)}
>
<span aria-hidden="true">{showPassword ? '👀' : '😝'}</span>
</IconButton>
</InputGroupAddon>
</InputGroup>
</Field>
<Field name="confirm-password" className="mt-8">
<FieldLabel className="system-sm-semibold">
{t(($) => $['account.confirmPassword'], { ns: 'accountSettings' })}
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onValueChange={setConfirmPassword}
autoComplete="new-password"
spellCheck={false}
readOnly={isPending}
/>
<InputGroupAddon align="inline-end">
<IconButton
size="lg"
aria-label={t(($) => $[showConfirmPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
<span aria-hidden="true">{showConfirmPassword ? '👀' : '😝'}</span>
</IconButton>
</InputGroupAddon>
</InputGroup>
</Field>
<div className="mt-10 flex justify-end">
<DialogClose render={<Button className="mr-2" disabled={isPending} />}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</DialogClose>
<Button type="submit" loading={isPending} variant="primary">
{isPasswordSet
? t(($) => $['operation.reset'], { ns: 'common' })
: t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</form>
)
}
export function ChangePasswordDialog({ isPasswordSet }: { isPasswordSet: boolean }) {
const { t } = useTranslation(['common', 'accountSettings'])
const actionsRef = useRef<DialogActions>(null)
const queryClient = useQueryClient()
const updatePassword = useMutation(
consoleQuery.account.password.post.mutationOptions({
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey }),
}),
)
const handleSave = async (password: AccountPasswordPayload) => {
try {
await updatePassword.mutateAsync({ body: password })
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
actionsRef.current?.close()
} catch (error) {
toast.error((error as Error).message)
}
}
return (
<Dialog
actionsRef={actionsRef}
onOpenChange={(open, details) => {
if (!open && updatePassword.isPending && details.reason !== 'imperative-action')
details.cancel()
}}
>
<DialogTrigger render={<Button />}>
{isPasswordSet
? t(($) => $['account.resetPassword'], { ns: 'accountSettings' })
: t(($) => $['account.setPassword'], { ns: 'accountSettings' })}
</DialogTrigger>
<DialogContent className="w-105! p-6!">
<ChangePasswordForm
isPasswordSet={isPasswordSet}
isPending={updatePassword.isPending}
onSave={handleSave}
/>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import type { DialogActions } from '@langgenius/dify-ui/dialog'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Input } from '@langgenius/dify-ui/input'
import { useMutation } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/notifications'
import { consoleQuery } from '@/service/console'
type EditAccountNameFormProps = {
name: string
isPending: boolean
onSave: (name: string) => Promise<void>
}
function EditAccountNameForm({ name, isPending, onSave }: EditAccountNameFormProps) {
const { t } = useTranslation(['common', 'accountSettings'])
const [draftName, setDraftName] = useState(name)
return (
<form
onSubmit={(event) => {
event.preventDefault()
if (!isPending && draftName) void onSave(draftName)
}}
>
<DialogTitle className="mb-6 title-2xl-semi-bold text-text-primary">
{t(($) => $['account.editName'], { ns: 'accountSettings' })}
</DialogTitle>
<Field name="name" className="gap-0">
<FieldLabel className="py-0 system-sm-semibold text-text-secondary">
{t(($) => $['account.name'], { ns: 'accountSettings' })}
</FieldLabel>
<Input
className="mt-2"
value={draftName}
onValueChange={setDraftName}
readOnly={isPending}
/>
</Field>
<div className="mt-10 flex justify-end">
<DialogClose render={<Button className="mr-2" disabled={isPending} />}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</DialogClose>
<Button type="submit" disabled={!draftName} loading={isPending} variant="primary">
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</form>
)
}
export function EditAccountNameDialog({ name }: { name: string }) {
const { t } = useTranslation(['common'])
const actionsRef = useRef<DialogActions>(null)
const updateProfile = useMutation(consoleQuery.account.profile.patch.mutationOptions())
const handleSave = async (name: string) => {
try {
await updateProfile.mutateAsync({ body: { name } })
toast.success(t(($) => $['actionMsg.modifiedSuccessfully']))
actionsRef.current?.close()
} catch (error) {
toast.error((error as Error).message)
}
}
return (
<Dialog
actionsRef={actionsRef}
onOpenChange={(open, details) => {
if (!open && updateProfile.isPending && details.reason !== 'imperative-action')
details.cancel()
}}
>
<DialogTrigger className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 system-sm-medium text-components-button-tertiary-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-accent-solid">
{t(($) => $['operation.edit'])}
</DialogTrigger>
<DialogContent className="w-105 p-6">
<EditAccountNameForm name={name} isPending={updateProfile.isPending} onSave={handleSave} />
</DialogContent>
</Dialog>
)
}

View File

@@ -3,25 +3,19 @@ import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type { IItem } from '@/app/components/header/account-setting/collapse'
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Input } from '@langgenius/dify-ui/input'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import PremiumBadge from '@/app/components/base/premium-badge'
import Collapse from '@/app/components/header/account-setting/collapse'
import { toast } from '@/app/notifications'
import { validPassword } from '@/config'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { updateUserProfile } from '@/service/common'
import { consoleQuery } from '@/service/console'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
import { ChangePasswordDialog } from './change-password-dialog'
import { EditAccountNameDialog } from './edit-account-name-dialog'
import EmailChangeModal from './email-change-modal'
const titleClassName = `
@@ -33,8 +27,7 @@ const descriptionClassName = `
type AccountAppItem = AppPartial & IItem
export default function AccountPage() {
const { t } = useTranslation(['common', 'login', 'accountSettings'])
const editNameInputId = useId()
const { t } = useTranslation(['common', 'accountSettings'])
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: appList } = useQuery(
consoleQuery.apps.get.queryOptions({
@@ -48,13 +41,9 @@ export default function AccountPage() {
}),
)
const apps = appList?.data || []
const queryClient = useQueryClient()
const updateProfile = useMutation(consoleQuery.account.profile.patch.mutationOptions())
// Cache is hydrated by CommonLayoutHydrationBoundary; this hits cache synchronously.
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const userProfile = userProfileResp.profile
const mutateUserProfile = () =>
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
const { data: enableEducationPlan } = useQuery(
consoleQuery.features.get.queryOptions({
select: (features) => features.education.enabled,
@@ -66,86 +55,11 @@ export default function AccountPage() {
select: ({ is_student }) => is_student ?? false,
}),
)
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')
const [editing, setEditing] = useState(false)
const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [showUpdateEmail, setShowUpdateEmail] = useState(false)
if (!userProfile) return null
const handleEditName = () => {
setEditNameModalVisible(true)
setEditName(userProfile.name)
}
const handleSaveName = async () => {
try {
setEditing(true)
await updateProfile.mutateAsync({ body: { name: editName } })
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
setEditNameModalVisible(false)
setEditing(false)
} catch (e) {
toast.error((e as Error).message)
setEditing(false)
}
}
const showErrorMessage = (message: string) => {
toast.error(message)
}
const valid = () => {
if (!password.trim()) {
showErrorMessage(t(($) => $['error.passwordEmpty'], { ns: 'login' }))
return false
}
if (!validPassword.test(password)) {
showErrorMessage(t(($) => $['error.passwordInvalid'], { ns: 'login' }))
return false
}
if (password !== confirmPassword) {
showErrorMessage(t(($) => $['account.notEqual'], { ns: 'accountSettings' }))
return false
}
return true
}
const resetPasswordForm = () => {
setCurrentPassword('')
setPassword('')
setConfirmPassword('')
}
const handleSavePassword = async () => {
if (!valid()) return
try {
setEditing(true)
await updateUserProfile({
url: 'account/password',
body: {
password: currentPassword,
new_password: password,
repeat_new_password: confirmPassword,
},
})
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
mutateUserProfile()
setEditPasswordModalVisible(false)
resetPasswordForm()
setEditing(false)
} catch (e) {
toast.error((e as Error).message)
setEditPasswordModalVisible(false)
setEditing(false)
}
}
const renderAppItem = (item: AccountAppItem) => {
const appIconType = zIconType.safeParse(item.icon_type).data ?? null
return (
@@ -194,13 +108,7 @@ export default function AccountPage() {
<div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 system-sm-regular text-components-input-text-filled">
<span className="pl-1">{userProfile.name}</span>
</div>
<button
type="button"
className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 system-sm-medium text-components-button-tertiary-text"
onClick={handleEditName}
>
{t(($) => $['operation.edit'], { ns: 'common' })}
</button>
<EditAccountNameDialog name={userProfile.name} />
</div>
</div>
<div className="mb-8">
@@ -232,11 +140,7 @@ export default function AccountPage() {
{t(($) => $['account.passwordTip'], { ns: 'accountSettings' })}
</div>
</div>
<Button onClick={() => setEditPasswordModalVisible(true)}>
{userProfile.is_password_set
? t(($) => $['account.resetPassword'], { ns: 'accountSettings' })
: t(($) => $['account.setPassword'], { ns: 'accountSettings' })}
</Button>
<ChangePasswordDialog isPasswordSet={userProfile.is_password_set} />
</div>
)}
<div className="mb-6 border border-divider-subtle" />
@@ -264,140 +168,6 @@ export default function AccountPage() {
</Button>
)}
</div>
<Dialog
open={editNameModalVisible}
onOpenChange={(open) => !open && setEditNameModalVisible(false)}
>
<DialogContent className="w-105 p-6">
<div className="mb-6 title-2xl-semi-bold text-text-primary">
{t(($) => $['account.editName'], { ns: 'accountSettings' })}
</div>
<label htmlFor={editNameInputId} className={`block ${titleClassName}`}>
{t(($) => $['account.name'], { ns: 'accountSettings' })}
</label>
<Input
id={editNameInputId}
className="mt-2"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<div className="mt-10 flex justify-end">
<Button className="mr-2" onClick={() => setEditNameModalVisible(false)}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button disabled={editing || !editName} variant="primary" onClick={handleSaveName}>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={editPasswordModalVisible}
onOpenChange={(open) => !open && (setEditPasswordModalVisible(false), resetPasswordForm())}
>
<DialogContent className="w-105! p-6!">
<div className="mb-6 title-2xl-semi-bold text-text-primary">
{userProfile.is_password_set
? t(($) => $['account.resetPassword'], { ns: 'accountSettings' })
: t(($) => $['account.setPassword'], { ns: 'accountSettings' })}
</div>
{userProfile.is_password_set && (
<Field name="current-password">
<FieldLabel className="system-sm-semibold">
{t(($) => $['account.currentPassword'], { ns: 'accountSettings' })}
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onValueChange={setCurrentPassword}
autoComplete="current-password"
spellCheck={false}
/>
<InputGroupAddon align="inline-end">
<IconButton
size="lg"
aria-label={t(($) => $[showCurrentPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
<span aria-hidden="true">{showCurrentPassword ? '👀' : '😝'}</span>
</IconButton>
</InputGroupAddon>
</InputGroup>
</Field>
)}
<Field name="new-password" className="mt-8">
<FieldLabel className="system-sm-semibold">
{userProfile.is_password_set
? t(($) => $['account.newPassword'], { ns: 'accountSettings' })
: t(($) => $['account.password'], { ns: 'accountSettings' })}
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showPassword ? 'text' : 'password'}
value={password}
onValueChange={setPassword}
autoComplete="new-password"
spellCheck={false}
/>
<InputGroupAddon align="inline-end">
<IconButton
size="lg"
aria-label={t(($) => $[showPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
onClick={() => setShowPassword(!showPassword)}
>
<span aria-hidden="true">{showPassword ? '👀' : '😝'}</span>
</IconButton>
</InputGroupAddon>
</InputGroup>
</Field>
<Field name="confirm-password" className="mt-8">
<FieldLabel className="system-sm-semibold">
{t(($) => $['account.confirmPassword'], { ns: 'accountSettings' })}
</FieldLabel>
<InputGroup>
<InputGroupInput
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onValueChange={setConfirmPassword}
autoComplete="new-password"
spellCheck={false}
/>
<InputGroupAddon align="inline-end">
<IconButton
size="lg"
aria-label={t(($) => $[showConfirmPassword ? 'hidePassword' : 'showPassword'], {
ns: 'login',
})}
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
<span aria-hidden="true">{showConfirmPassword ? '👀' : '😝'}</span>
</IconButton>
</InputGroupAddon>
</InputGroup>
</Field>
<div className="mt-10 flex justify-end">
<Button
className="mr-2"
onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button disabled={editing} variant="primary" onClick={handleSavePassword}>
{userProfile.is_password_set
? t(($) => $['operation.reset'], { ns: 'common' })
: t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
{showDeleteAccountModal && (
<DeleteAccount
onCancel={() => setShowDeleteAccountModal(false)}

View File

@@ -2,11 +2,11 @@
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -89,14 +89,14 @@ export default function AccessRulesBatchAction({
})}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton disabled={isDeleting}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} onClick={handleDelete}>
{t(($) => $['operation.sure'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>

View File

@@ -3,7 +3,7 @@ import type { ApiKeyList as DatasetApiKeyList } from '@dify/contracts/api/consol
import type { EnvironmentApiKey } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ComponentProps } from 'react'
import { QueryClientProvider, skipToken } from '@tanstack/react-query'
import { act, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach } from 'vite-plus/test'
import { render } from '@/test/console/render'
@@ -14,6 +14,7 @@ const apiMocks = vi.hoisted(() => ({
appKeys: [] as AppApiKeyList['data'],
datasetKeys: [] as DatasetApiKeyList['data'],
environmentKeys: [] as EnvironmentApiKey[],
knowledgeBases: vi.fn(),
listApp: vi.fn(),
createApp: vi.fn(),
deleteApp: vi.fn(),
@@ -25,6 +26,10 @@ const apiMocks = vi.hoisted(() => ({
deleteEnvironment: vi.fn(),
}))
vi.mock('@/service/base', () => ({
get: (url: string) => apiMocks.knowledgeBases(url),
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
apps: {
@@ -140,6 +145,14 @@ const environmentScope = {
environmentId: 'staging',
} as const
function createPendingRequest<T>() {
let reject!: (reason: Error) => void
const promise = new Promise<T>((_resolve, rejectPromise) => {
reject = rejectPromise
})
return { promise, reject }
}
async function renderModal(
scope: ComponentProps<typeof ApiKeyModal>['scope'],
overrides: { canManage?: boolean } = {},
@@ -164,7 +177,7 @@ async function renderModal(
async function confirmKeyDeletion(accessibleName: string) {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const deleteButton = screen.getByRole('button', { name: accessibleName })
const deleteButton = await screen.findByRole('button', { name: accessibleName })
await user.click(deleteButton)
await act(async () => {
vi.runAllTimers()
@@ -180,6 +193,13 @@ describe('ApiKeyModal', () => {
apiMocks.appKeys = []
apiMocks.datasetKeys = []
apiMocks.environmentKeys = []
apiMocks.knowledgeBases.mockResolvedValue({
data: [{ id: 'kb-1', name: 'Product guide' }],
page: 1,
has_more: false,
total: 1,
limit: 20,
})
apiMocks.createApp.mockResolvedValue({ token: 'new-app-token-123' })
apiMocks.deleteApp.mockResolvedValue(undefined)
apiMocks.createDataset.mockResolvedValue({ token: 'new-dataset-token-123' })
@@ -410,4 +430,151 @@ describe('ApiKeyModal', () => {
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('blocks dismissal and duplicate creation while pending, then allows retry after failure', async () => {
apiMocks.appKeys = [
{ id: 'app-key-1', token: 'app-secret-token-123456789', type: 'app', created_at: 1 },
]
const pending = createPendingRequest<{ token: string }>()
apiMocks.createApp.mockReturnValueOnce(pending.promise)
const { onOpenChange } = await renderModal(appScope)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const create = screen.getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' })
await user.click(create)
await waitFor(() => expect(create).toHaveAttribute('aria-disabled', 'true'))
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeDisabled()
expect(screen.getByRole('button', { name: /common.operation.delete/ })).toBeDisabled()
await user.keyboard('{Escape}')
await user.click(create)
expect(onOpenChange).not.toHaveBeenCalled()
expect(apiMocks.createApp).toHaveBeenCalledTimes(1)
await act(async () => pending.reject(new Error('Creation failed')))
await waitFor(() => expect(create).not.toHaveAttribute('aria-disabled', 'true'))
await user.click(create)
expect(
await screen.findByRole('textbox', { name: 'appApi.apiKeyModal.secretKey' }),
).toHaveValue('new-app-token-123')
expect(apiMocks.createApp).toHaveBeenCalledTimes(2)
})
it('keeps deletion confirmation pending and failed, and closes it only after successful retry', async () => {
apiMocks.appKeys = [
{ id: 'app-key-1', token: 'app-secret-token-123456789', type: 'app', created_at: 1 },
]
const pending = createPendingRequest<void>()
apiMocks.deleteApp.mockReturnValueOnce(pending.promise)
const { onOpenChange } = await renderModal(appScope)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const deleteButton = await screen.findByRole('button', {
name: 'common.operation.delete app...cret-token-123456789',
})
await user.click(deleteButton)
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(deleteButton).toHaveFocus())
await confirmKeyDeletion('common.operation.delete app...cret-token-123456789')
const confirmation = screen.getByRole('alertdialog')
const confirm = within(confirmation).getByRole('button', { name: 'common.operation.confirm' })
await waitFor(() => expect(confirm).toHaveAttribute('aria-disabled', 'true'))
expect(
within(confirmation).getByRole('button', { name: 'common.operation.cancel' }),
).toBeDisabled()
await user.keyboard('{Escape}')
await user.click(confirm)
expect(confirmation).toBeInTheDocument()
expect(onOpenChange).not.toHaveBeenCalled()
expect(apiMocks.deleteApp).toHaveBeenCalledTimes(1)
await act(async () => pending.reject(new Error('Deletion failed')))
await waitFor(() => expect(confirm).not.toHaveAttribute('aria-disabled', 'true'))
await user.click(confirm)
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(apiMocks.deleteApp).toHaveBeenCalledTimes(2)
await waitFor(() =>
expect(
screen.getByRole('button', {
name: 'appApi.apiKeyModal.createNewSecretKey',
}),
).toHaveFocus(),
)
})
it('loads knowledge bases only from the open picker and resets selection after cancellation', async () => {
await renderModal(datasetScope)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const openScope = screen.getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' })
expect(apiMocks.knowledgeBases).not.toHaveBeenCalled()
await user.click(openScope)
const scope = screen.getByRole('dialog', { name: 'appApi.apiKeyModal.addTitle' })
await user.click(within(scope).getByRole('radio', { name: /scopeSpecificDatasets/ }))
expect(apiMocks.knowledgeBases).not.toHaveBeenCalled()
await user.click(
within(scope).getByRole('button', { name: 'appApi.apiKeyModal.addKnowledgeBase' }),
)
await user.click(await screen.findByRole('checkbox', { name: 'Product guide' }))
expect(apiMocks.knowledgeBases).toHaveBeenCalledWith('/datasets?page=1')
await user.keyboard('{Escape}')
await user.click(within(scope).getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'appApi.apiKeyModal.addTitle' }),
).not.toBeInTheDocument(),
)
await user.click(openScope)
const reopened = screen.getByRole('dialog', { name: 'appApi.apiKeyModal.addTitle' })
expect(within(reopened).getByRole('radio', { name: /scopeAllDatasets/ })).toBeChecked()
await user.click(within(reopened).getByRole('radio', { name: /scopeSpecificDatasets/ }))
expect(
within(reopened).getByText('appApi.apiKeyModal.noKnowledgeBasesSelected'),
).toBeInTheDocument()
expect(within(reopened).getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
})
it('freezes scope while creating and retries the same selection after failure', async () => {
const pending = createPendingRequest<{ token: string }>()
apiMocks.createDataset.mockReturnValueOnce(pending.promise)
const { onOpenChange } = await renderModal(datasetScope)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await user.click(screen.getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' }))
const scope = screen.getByRole('dialog', { name: 'appApi.apiKeyModal.addTitle' })
await user.click(within(scope).getByRole('radio', { name: /scopeSpecificDatasets/ }))
await user.click(
within(scope).getByRole('button', { name: 'appApi.apiKeyModal.addKnowledgeBase' }),
)
await user.click(await screen.findByRole('checkbox', { name: 'Product guide' }))
await user.keyboard('{Escape}')
const create = within(scope).getByRole('button', { name: 'common.operation.create' })
await user.click(create)
await waitFor(() => expect(create).toHaveAttribute('aria-disabled', 'true'))
expect(within(scope).getByRole('radio', { name: /scopeAllDatasets/ })).toHaveAttribute(
'aria-disabled',
'true',
)
for (const name of [
'common.operation.remove',
'common.operation.close',
'common.operation.cancel',
'appApi.apiKeyModal.addKnowledgeBase',
])
expect(within(scope).getByRole('button', { name })).toBeDisabled()
await user.keyboard('{Escape}')
await user.click(create)
expect(onOpenChange).not.toHaveBeenCalled()
expect(apiMocks.createDataset).toHaveBeenCalledTimes(1)
await act(async () => pending.reject(new Error('Creation failed')))
await waitFor(() => expect(create).not.toHaveAttribute('aria-disabled', 'true'))
expect(within(scope).getByText('Product guide')).toBeInTheDocument()
await user.click(create)
await waitFor(() => expect(apiMocks.createDataset).toHaveBeenCalledTimes(2))
expect(apiMocks.createDataset).toHaveBeenNthCalledWith(2, { body: { dataset_ids: ['kb-1'] } })
expect(
await screen.findByRole('textbox', { name: 'appApi.apiKeyModal.secretKey' }),
).toHaveValue('new-dataset-token-123')
await user.click(screen.getByRole('button', { name: 'appApi.actionMsg.ok' }))
await waitFor(() =>
expect(
screen.queryByRole('textbox', { name: 'appApi.apiKeyModal.secretKey' }),
).not.toBeInTheDocument(),
)
expect(
screen.getByRole('dialog', { name: 'appApi.apiKeyModal.apiSecretKey' }),
).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,311 @@
import type {
AgentApiAccessResponse,
AgentAppDetailWithSite,
ApiKeyItem,
} from '@dify/contracts/api/console/agent/types.gen'
import type { ReactNode } from 'react'
import { QueryClient } from '@tanstack/react-query'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { ApiSecretKeyButton } from '@/app/components/app/access-point/shared/api-secret-key-button'
import { ServiceApi } from '@/app/components/datasets/extra-info/service-api'
import { AgentPermission } from '@/features/agent-v2/acl'
import { ServiceApiAccessCard } from '@/features/agent-v2/agent-detail/access/components/service-api-access-card'
import { consoleQuery } from '@/service/console'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace'
import { seedSystemFeatures } from '@/test/console/query-data'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { seedWorkspacePermissionsQuery } from '@/test/console/workspace-permissions'
import { createAppDetailFixture } from '@/test/fixtures/app'
const { transport, datasetGet } = vi.hoisted(() => ({ transport: vi.fn(), datasetGet: vi.fn() }))
vi.mock('@/service/console/browser', () => ({ consoleBrowserLink: { call: transport } }))
const title = 'appApi.apiKeyModal.apiSecretKey'
const createLabel = 'appApi.apiKeyModal.createNewSecretKey'
const closeLabel = 'common.operation.close'
const clients: QueryClient[] = []
const key: ApiKeyItem = { id: 'key-1', token: 'app-secret-browser-token', type: 'app' }
const apiAccess = {
access_ready: true,
enabled: true,
api_key_count: 0,
api_rph: 0,
api_rpm: 0,
service_api_base_url: 'https://api.example.com/v1',
chat_endpoint: '',
conversations_endpoint: '',
files_upload_endpoint: '',
info_endpoint: '',
messages_endpoint: '',
meta_endpoint: '',
parameters_endpoint: '',
stop_endpoint: '',
streaming_only: false,
} satisfies AgentApiAccessResponse
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((complete, fail) => {
resolve = complete
reject = fail
})
return { promise, resolve, reject }
}
async function renderOwner(children: ReactNode) {
const client = new QueryClient({
defaultOptions: {
queries: { staleTime: Infinity, retry: false },
mutations: { retry: false },
},
})
clients.push(client)
seedAccountProfileQuery(client)
seedCurrentWorkspaceQuery(client)
seedWorkspacePermissionsQuery(client, ['dataset.api_key.manage'])
seedSystemFeatures(client)
const agent = {
...createAppDetailFixture({
id: 'agent-1',
permission_keys: [AgentPermission.AccessPointManage],
}),
deleted_tools: [],
model_config: null,
access_ready: true,
debug_conversation_has_messages: false,
debug_conversation_message_count: 0,
hidden_app_backed: false,
} satisfies AgentAppDetailWithSite
client.setQueryData(
consoleQuery.agent.byAgentId.get.queryOptions({ input: { params: { agent_id: 'agent-1' } } })
.queryKey,
agent,
)
client.setQueryData(
consoleQuery.agent.byAgentId.apiAccess.get.queryOptions({
input: { params: { agent_id: 'agent-1' } },
}).queryKey,
apiAccess,
)
return render(<QueryClientTestProvider queryClient={client}>{children}</QueryClientTestProvider>)
}
function requests(path: string) {
return transport.mock.calls.filter(([parts]) => parts.join('.') === path)
}
beforeEach(async () => {
await page.viewport(1280, 900)
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
transport.mockReset()
transport.mockImplementation(async (parts: readonly string[]) => {
const path = parts.join('.')
if (path.endsWith('apiKeys.get')) return { data: [] }
if (path === 'agent.byAgentId.apiAccess.get') return apiAccess
throw new Error(`Unexpected Console request: ${path}`)
})
datasetGet.mockReset()
datasetGet.mockResolvedValue({
data: [{ id: 'dataset-1', name: 'Knowledge one' }],
page: 1,
has_more: false,
limit: 20,
total: 1,
})
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (!url.includes('/datasets?')) throw new Error(`Unexpected fetch: ${url}`)
return Response.json(await datasetGet(url))
})
})
afterEach(() => {
clients.splice(0).forEach((client) => client.clear())
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it.each(['app', 'agent'] as const)(
'keeps the %s manager pending, retries failure, and retains the result through exit before returning focus',
async (kind) => {
const pending = deferred<ApiKeyItem>()
const createPath =
kind === 'app' ? 'apps.byResourceId.apiKeys.post' : 'agent.byAgentId.apiKeys.post'
const originalTransport = transport.getMockImplementation()!
transport.mockImplementation((parts: readonly string[]) =>
parts.join('.') === createPath
? requests(createPath).length === 1
? pending.promise
: Promise.resolve(key)
: originalTransport(parts),
)
const screen = await renderOwner(
kind === 'app' ? (
<ApiSecretKeyButton appId="app-1" canManage />
) : (
<ServiceApiAccessCard agentId="agent-1" />
),
)
const entry = screen.getByRole('button', {
name:
kind === 'app'
? /appApi.apiKeyModal.apiSecretKey/
: /agentV2.agentDetail.access.serviceApi.actions.apiKey/,
})
await entry.click()
const manager = screen.getByRole('dialog', { name: title })
const create = manager.getByRole('button', { name: createLabel })
await create.click()
await expect.element(create).toHaveFocus()
await expect.element(manager.getByRole('button', { name: closeLabel })).toBeDisabled()
await userEvent.keyboard('{Enter}{Escape}')
await expect.element(manager).toBeVisible()
expect(requests(createPath)).toHaveLength(1)
pending.reject(new Error('Create failed'))
await expect.element(create).not.toHaveAttribute('aria-disabled', 'true')
await create.click()
const result = screen.getByRole('dialog', { name: title }).last()
await expect.element(result.getByText('appApi.apiKeyModal.generateTips')).toBeVisible()
expect(requests(createPath)).toHaveLength(2)
const clipboard = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
await result.getByRole('button', { name: 'common.operation.copy' }).click()
await expect.poll(() => clipboard.mock.calls).toEqual([[key.token]])
const popup = result.element()
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
const exitValue = new Promise<boolean>((resolve) => {
const onTransition = (event: Event) => {
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
popup.removeEventListener('transitionrun', onTransition)
const input = popup.querySelector<HTMLInputElement>('input')
resolve(
popup.isConnected &&
(input ? input.value === key.token : popup.textContent?.includes(key.token) === true) &&
popup.querySelector('[aria-label="common.operation.copied"]') !== null,
)
}
popup.addEventListener('transitionrun', onTransition)
})
await result.getByRole('button', { name: 'appApi.actionMsg.ok' }).click()
expect(await exitValue).toBe(true)
await expect.poll(() => popup.isConnected).toBe(false)
await expect.element(create).toHaveFocus()
await manager.getByRole('button', { name: closeLabel }).click()
await expect.element(manager).not.toBeInTheDocument()
await expect.element(entry).toHaveFocus()
},
)
it('resets cancelled dataset scope, fetches only from its picker, and hands creation back to the manager', async () => {
const pending = deferred<ApiKeyItem>()
const originalTransport = transport.getMockImplementation()!
transport.mockImplementation((parts: readonly string[]) =>
parts.join('.') === 'datasets.apiKeys.post' ? pending.promise : originalTransport(parts),
)
const screen = await renderOwner(<ServiceApi apiBaseUrl="https://api.example.com/v1" />)
const entry = screen.getByRole('button', { name: 'dataset.serviceApi.title' })
await entry.click()
await screen.getByRole('button', { name: 'dataset.serviceApi.card.apiKey' }).click()
const manager = screen.getByRole('dialog', { name: title })
const create = manager.getByRole('button', { name: createLabel })
await create.click()
const scope = screen.getByRole('dialog', { name: 'appApi.apiKeyModal.addTitle' })
await scope.getByRole('radio', { name: /^appApi.apiKeyModal.scopeSpecificDatasets / }).click()
expect(datasetGet).not.toHaveBeenCalled()
await scope.getByRole('button', { name: 'appApi.apiKeyModal.addKnowledgeBase' }).click()
await screen.getByRole('checkbox', { name: 'Knowledge one' }).click()
expect(datasetGet).toHaveBeenCalledTimes(1)
await userEvent.keyboard('{Escape}')
await scope.getByRole('button', { name: 'common.operation.cancel' }).click()
await expect.element(scope).not.toBeInTheDocument()
await expect.element(create).toHaveFocus()
await create.click()
await expect
.element(scope.getByRole('radio', { name: /^appApi.apiKeyModal.scopeAllDatasets / }))
.toBeChecked()
await scope.getByRole('radio', { name: /^appApi.apiKeyModal.scopeSpecificDatasets / }).click()
await expect.element(scope.getByText('appApi.apiKeyModal.noKnowledgeBasesSelected')).toBeVisible()
expect(datasetGet).toHaveBeenCalledTimes(1)
await scope.getByRole('radio', { name: /^appApi.apiKeyModal.scopeAllDatasets / }).click()
await scope.getByRole('button', { name: 'common.operation.create' }).click()
await expect.element(scope.getByRole('button', { name: closeLabel })).toBeDisabled()
await userEvent.keyboard('{Enter}{Escape}')
await expect.element(scope).toBeVisible()
expect(requests('datasets.apiKeys.post')).toHaveLength(1)
pending.resolve(key)
await expect.element(scope).not.toBeInTheDocument()
const result = screen.getByRole('dialog', { name: title }).last()
await expect
.element(result.getByRole('textbox', { name: 'appApi.apiKeyModal.secretKey' }))
.toHaveValue(key.token)
await result.getByRole('button', { name: 'appApi.actionMsg.ok' }).click()
await expect
.element(screen.getByRole('textbox', { name: 'appApi.apiKeyModal.secretKey' }))
.not.toBeInTheDocument()
await expect.element(create).toHaveFocus()
await manager.getByRole('button', { name: closeLabel }).click()
await expect.element(manager).not.toBeInTheDocument()
await expect.element(entry).toHaveFocus()
})
it.each(['app', 'agent'] as const)(
'keeps the %s delete confirmation pending and returns focus inside the manager after its row is removed',
async (kind) => {
const pending = deferred<{ result: string }>()
let existingKeys = [key]
const listPath =
kind === 'app' ? 'apps.byResourceId.apiKeys.get' : 'agent.byAgentId.apiKeys.get'
const deletePath =
kind === 'app'
? 'apps.byResourceId.apiKeys.byApiKeyId.delete'
: 'agent.byAgentId.apiKeys.byApiKeyId.delete'
transport.mockImplementation((parts: readonly string[]) => {
const path = parts.join('.')
if (path === listPath) return Promise.resolve({ data: existingKeys })
if (path === deletePath) return pending.promise
if (path === 'agent.byAgentId.apiAccess.get') return Promise.resolve(apiAccess)
throw new Error(`Unexpected Console request: ${path}`)
})
const screen = await renderOwner(
kind === 'app' ? (
<ApiSecretKeyButton appId="app-1" canManage />
) : (
<ServiceApiAccessCard agentId="agent-1" />
),
)
await screen
.getByRole('button', {
name:
kind === 'app'
? /appApi.apiKeyModal.apiSecretKey/
: /agentV2.agentDetail.access.serviceApi.actions.apiKey/,
})
.click()
const manager = screen.getByRole('dialog', { name: title })
const deleteButton = manager.getByRole('button', { name: /^common.operation.delete/ })
await deleteButton.click()
const confirmation = screen.getByRole('alertdialog')
await confirmation.getByRole('button', { name: 'common.operation.cancel' }).click()
await expect.element(confirmation).not.toBeInTheDocument()
await expect.element(deleteButton).toHaveFocus()
await deleteButton.click()
const confirm = confirmation.getByRole('button', { name: 'common.operation.confirm' })
await confirm.click()
await expect.element(confirm).toHaveFocus()
await expect
.element(confirmation.getByRole('button', { name: 'common.operation.cancel' }))
.toBeDisabled()
await userEvent.keyboard('{Enter}{Escape}')
await expect.element(confirmation).toBeVisible()
expect(requests(deletePath)).toHaveLength(1)
existingKeys = []
pending.resolve({ result: 'success' })
await expect.element(confirmation).not.toBeInTheDocument()
await expect
.element(manager.getByRole('button', { name: /^common.operation.delete/ }))
.not.toBeInTheDocument()
await expect.element(manager.getByRole('button', { name: createLabel })).toHaveFocus()
},
)

View File

@@ -1,12 +1,13 @@
'use client'
import type { ApiKeyItem } from '@dify/contracts/api/console/apps/types.gen'
import type { DialogProps } from '@langgenius/dify-ui/dialog'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -20,7 +21,7 @@ import {
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { currentWorkspaceAtom } from '@/context/workspace-state'
@@ -45,11 +46,11 @@ type ApiKeyModalProps = {
export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModalProps) {
const { t } = useTranslation(['appApi', 'common'])
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const createButtonRef = useRef<HTMLButtonElement>(null)
const deleteReturnFocusRef = useRef<HTMLButtonElement | null>(null)
const [deleteKeyId, setDeleteKeyId] = useState<string>()
const [createdApiKey, setCreatedApiKey] = useState<CreatedApiKey>()
const [scopeDialogOpen, setScopeDialogOpen] = useState(false)
// Bumped on each open so the scope dialog remounts with fresh selection state.
const [scopeDialogKey, setScopeDialogKey] = useState(0)
const appApiKeysQuery = useQuery(
consoleQuery.apps.byResourceId.apiKeys.get.queryOptions({
@@ -110,7 +111,11 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
: deleteEnvironmentApiKey.isPending
const createDisabled = !currentWorkspace.id || !canManage
const handleOpenChange = (nextOpen: boolean) => {
const handleOpenChange: DialogProps['onOpenChange'] = (nextOpen, details) => {
if (!nextOpen && (isCreating || isDeleting)) {
details.cancel()
return
}
if (!nextOpen) {
setDeleteKeyId(undefined)
setCreatedApiKey(undefined)
@@ -120,7 +125,7 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
}
const handleCreate = () => {
if (createDisabled || isCreating) return
if (createDisabled || isCreating || isDeleting) return
switch (scope.type) {
case 'app':
@@ -132,7 +137,6 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
case 'dataset':
// Dataset keys pick a knowledge-base scope before creation; the scope dialog
// owns that step and calls handleCreateDatasetKey with the chosen ids.
setScopeDialogKey((key) => key + 1)
setScopeDialogOpen(true)
break
case 'environment':
@@ -160,7 +164,10 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
const handleDelete = () => {
if (!deleteKeyId || isDeleting) return
const onSuccess = () => setDeleteKeyId(undefined)
const onSuccess = () => {
deleteReturnFocusRef.current = createButtonRef.current
setDeleteKeyId(undefined)
}
switch (scope.type) {
case 'app':
deleteAppApiKey.mutate(
@@ -199,6 +206,7 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
</DialogDescription>
</div>
<DialogClose
disabled={isCreating || isDeleting}
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
@@ -218,12 +226,21 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
<ApiKeyTable
apiKeys={apiKeys}
canManage={canManage}
disabled={isCreating || isDeleting}
showScope={scope.type === 'dataset'}
onDeleteRequest={setDeleteKeyId}
onDeleteRequest={(id, trigger) => {
deleteReturnFocusRef.current = trigger
setDeleteKeyId(id)
}}
/>
)}
<div className="flex shrink-0 px-6 py-4">
<Button disabled={createDisabled} loading={isCreating} onClick={handleCreate}>
<Button
ref={createButtonRef}
disabled={createDisabled || isDeleting}
loading={isCreating}
onClick={handleCreate}
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
{t(($) => $['apiKeyModal.createNewSecretKey'], { ns: 'appApi' })}
</Button>
@@ -232,11 +249,21 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
</Dialog>
<AlertDialog
open={deleteKeyId !== undefined}
onOpenChange={(open) => {
if (!open && !isDeleting) setDeleteKeyId(undefined)
onOpenChange={(nextOpen, details) => {
if (isDeleting) {
details.cancel()
return
}
if (!nextOpen) setDeleteKeyId(undefined)
}}
>
<AlertDialogContent>
<AlertDialogContent
finalFocus={() =>
deleteReturnFocusRef.current?.isConnected
? deleteReturnFocusRef.current
: createButtonRef.current
}
>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['actionMsg.deleteConfirmTitle'], { ns: 'appApi' })}
@@ -245,14 +272,14 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
{t(($) => $['actionMsg.deleteConfirmTips'], { ns: 'appApi' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton disabled={isDeleting}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} onClick={handleDelete}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreatedApiKeyDialog
@@ -264,7 +291,6 @@ export function ApiKeyModal({ open, canManage, scope, onOpenChange }: ApiKeyModa
/>
{scope.type === 'dataset' && (
<DatasetScopeDialog
key={scopeDialogKey}
open={scopeDialogOpen}
isCreating={createDatasetApiKey.isPending}
onOpenChange={setScopeDialogOpen}

View File

@@ -8,14 +8,16 @@ import useTimestamp from '@/hooks/use-timestamp'
type ApiKeyTableProps = {
apiKeys: Array<ApiKeyItem | EnvironmentApiKey>
canManage: boolean
disabled?: boolean
// Dataset keys carry a knowledge-base scope; enable the SCOPE column to surface it.
showScope?: boolean
onDeleteRequest: (apiKeyId: string) => void
onDeleteRequest: (apiKeyId: string, trigger: HTMLButtonElement) => void
}
export function ApiKeyTable({
apiKeys,
canManage,
disabled,
showScope = false,
onDeleteRequest,
}: ApiKeyTableProps) {
@@ -79,8 +81,9 @@ export function ApiKeyTable({
<CopyFeedback content={apiKey.token} />
{canManage && (
<IconButton
disabled={disabled}
aria-label={`${t(($) => $['operation.delete'], { ns: 'common' })} ${maskToken(apiKey.token)}`}
onClick={() => onDeleteRequest(apiKey.id)}
onClick={(event) => onDeleteRequest(apiKey.id, event.currentTarget)}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</IconButton>

View File

@@ -9,6 +9,7 @@ import {
} from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CopyFeedback } from '@/app/components/base/copy-feedback'
@@ -19,51 +20,60 @@ type CreatedApiKeyDialogProps = {
}
export function CreatedApiKeyDialog({ open, onOpenChange, value }: CreatedApiKeyDialogProps) {
const { t } = useTranslation(['appApi', 'common'])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0">
<div className="flex flex-col gap-1 px-6 pt-6 pr-14 pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}
</DialogTitle>
<DialogDescription className="system-sm-regular text-text-tertiary">
{t(($) => $['apiKeyModal.generateTips'], { ns: 'appApi' })}
</DialogDescription>
</div>
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<div className="px-6 pb-4">
<InputGroup>
<InputGroupInput
aria-label={t(($) => $['apiKeyModal.secretKey'], { ns: 'appApi' })}
autoComplete="off"
className="font-mono"
readOnly
spellCheck={false}
value={value}
/>
<InputGroupAddon align="inline-end" className="pe-1">
<CopyFeedback content={value} />
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex justify-end px-6 pb-6">
<Button variant="primary" onClick={() => onOpenChange(false)}>
{t(($) => $['actionMsg.ok'], { ns: 'appApi' })}
</Button>
</div>
<CreatedApiKeyContent value={value} />
</DialogContent>
</Dialog>
)
}
function CreatedApiKeyContent({ value }: Pick<CreatedApiKeyDialogProps, 'value'>) {
const [token] = useState(value)
const { t } = useTranslation(['appApi', 'common'])
return (
<>
<div className="flex flex-col gap-1 px-6 pt-6 pr-14 pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}
</DialogTitle>
<DialogDescription className="system-sm-regular text-text-tertiary">
{t(($) => $['apiKeyModal.generateTips'], { ns: 'appApi' })}
</DialogDescription>
</div>
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<div className="px-6 pb-4">
<InputGroup>
<InputGroupInput
aria-label={t(($) => $['apiKeyModal.secretKey'], { ns: 'appApi' })}
autoComplete="off"
className="font-mono"
readOnly
spellCheck={false}
value={token}
/>
<InputGroupAddon align="inline-end" className="pe-1">
<CopyFeedback content={token} />
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex justify-end px-6 pb-6">
<DialogClose render={<Button variant="primary" />}>
{t(($) => $['actionMsg.ok'], { ns: 'appApi' })}
</DialogClose>
</div>
</>
)
}

View File

@@ -39,15 +39,35 @@ export function DatasetScopeDialog({
onOpenChange,
onConfirm,
}: DatasetScopeDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(nextOpen, details) => {
if (!nextOpen && isCreating) {
details.cancel()
return
}
onOpenChange(nextOpen)
}}
>
<DialogContent className="flex w-140 flex-col overflow-hidden p-0">
<DatasetScopeContent isCreating={isCreating} onConfirm={onConfirm} />
</DialogContent>
</Dialog>
)
}
function DatasetScopeContent({
isCreating,
onConfirm,
}: Pick<DatasetScopeDialogProps, 'isCreating' | 'onConfirm'>) {
const { t } = useTranslation(['appApi', 'common'])
const [scope, setScope] = useState<Scope>('all')
const [selected, setSelected] = useState<SelectedKb[]>([])
const [pickerOpen, setPickerOpen] = useState(false)
const [keyword, setKeyword] = useState('')
// Selection state resets by remounting: the parent bumps this dialog's `key` each time
// it opens, so every open starts from these defaults with no reset-in-effect.
const { data: datasetsPages } = useInfiniteDatasets({ keyword }, { enabled: open && pickerOpen })
const { data: datasetsPages } = useInfiniteDatasets({ keyword }, { enabled: pickerOpen })
const datasets = useMemo(
() => (datasetsPages?.pages ?? []).flatMap((page) => page.data),
[datasetsPages],
@@ -64,163 +84,160 @@ export function DatasetScopeDialog({
const canCreate = scope === 'all' || selected.length > 0
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && isCreating) return
onOpenChange(nextOpen)
}
const handleConfirm = () => {
if (!canCreate || isCreating) return
onConfirm(scope === 'specific' ? selected.map((kb) => kb.id) : [])
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex w-140 flex-col overflow-hidden p-0">
<div className="flex shrink-0 flex-col gap-1 px-6 pt-6 pr-14 pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['apiKeyModal.addTitle'], { ns: 'appApi' })}
</DialogTitle>
<DialogDescription className="system-sm-regular text-text-tertiary">
{t(($) => $['apiKeyModal.addSubtitle'], { ns: 'appApi' })}
</DialogDescription>
<>
<div className="flex shrink-0 flex-col gap-1 px-6 pt-6 pr-14 pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['apiKeyModal.addTitle'], { ns: 'appApi' })}
</DialogTitle>
<DialogDescription className="system-sm-regular text-text-tertiary">
{t(($) => $['apiKeyModal.addSubtitle'], { ns: 'appApi' })}
</DialogDescription>
</div>
<DialogClose
disabled={isCreating}
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<div className="flex flex-col gap-3 px-6 py-4">
<div className="system-sm-semibold text-text-secondary">
{t(($) => $['apiKeyModal.knowledgeBaseAccess'], { ns: 'appApi' })}
</div>
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
disabled={isCreating}
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<div className="flex flex-col gap-3 px-6 py-4">
<div className="system-sm-semibold text-text-secondary">
{t(($) => $['apiKeyModal.knowledgeBaseAccess'], { ns: 'appApi' })}
</div>
<RadioGroup<Scope> value={scope} onValueChange={setScope}>
<div className="flex flex-col gap-3">
<label htmlFor="api-key-scope-all" className="flex cursor-pointer items-start gap-2">
<Radio id="api-key-scope-all" value="all" className="mt-0.5" />
<div className="flex flex-col">
<span className="system-sm-medium text-text-secondary">
{t(($) => $['apiKeyModal.scopeAllDatasets'], { ns: 'appApi' })}
</span>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['apiKeyModal.scopeAllDatasetsTip'], { ns: 'appApi' })}
</span>
</div>
</label>
<label
htmlFor="api-key-scope-specific"
className="flex cursor-pointer items-start gap-2"
>
<Radio id="api-key-scope-specific" value="specific" className="mt-0.5" />
<div className="flex flex-col">
<span className="system-sm-medium text-text-secondary">
{t(($) => $['apiKeyModal.scopeSpecificDatasets'], { ns: 'appApi' })}
</span>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['apiKeyModal.scopeSpecificDatasetsTip'], { ns: 'appApi' })}
</span>
</div>
</label>
</div>
</RadioGroup>
{scope === 'specific' && (
<div className="flex flex-col gap-2">
<div className="system-sm-semibold text-text-secondary">
{t(($) => $['apiKeyModal.selectedKnowledgeBases'], { ns: 'appApi' })}
<RadioGroup<Scope> disabled={isCreating} value={scope} onValueChange={setScope}>
<div className="flex flex-col gap-3">
<label htmlFor="api-key-scope-all" className="flex cursor-pointer items-start gap-2">
<Radio id="api-key-scope-all" value="all" className="mt-0.5" />
<div className="flex flex-col">
<span className="system-sm-medium text-text-secondary">
{t(($) => $['apiKeyModal.scopeAllDatasets'], { ns: 'appApi' })}
</span>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['apiKeyModal.scopeAllDatasetsTip'], { ns: 'appApi' })}
</span>
</div>
<div className="flex flex-col gap-2 rounded-lg border-[0.5px] border-divider-subtle bg-components-panel-bg p-3">
{selected.length === 0 ? (
<div className="py-0.5 system-xs-regular text-text-tertiary">
{t(($) => $['apiKeyModal.noKnowledgeBasesSelected'], { ns: 'appApi' })}
</label>
<label
htmlFor="api-key-scope-specific"
className="flex cursor-pointer items-start gap-2"
>
<Radio id="api-key-scope-specific" value="specific" className="mt-0.5" />
<div className="flex flex-col">
<span className="system-sm-medium text-text-secondary">
{t(($) => $['apiKeyModal.scopeSpecificDatasets'], { ns: 'appApi' })}
</span>
<span className="system-xs-regular text-text-tertiary">
{t(($) => $['apiKeyModal.scopeSpecificDatasetsTip'], { ns: 'appApi' })}
</span>
</div>
</label>
</div>
</RadioGroup>
{scope === 'specific' && (
<div className="flex flex-col gap-2">
<div className="system-sm-semibold text-text-secondary">
{t(($) => $['apiKeyModal.selectedKnowledgeBases'], { ns: 'appApi' })}
</div>
<div className="flex flex-col gap-2 rounded-lg border-[0.5px] border-divider-subtle bg-components-panel-bg p-3">
{selected.length === 0 ? (
<div className="py-0.5 system-xs-regular text-text-tertiary">
{t(($) => $['apiKeyModal.noKnowledgeBasesSelected'], { ns: 'appApi' })}
</div>
) : (
<div className="flex flex-col gap-2">
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['apiKeyModal.selectedKnowledgeBasesCount'], {
ns: 'appApi',
count: selected.length,
})}
</div>
) : (
<div className="flex flex-col gap-2">
<div className="system-xs-regular text-text-tertiary">
{t(($) => $['apiKeyModal.selectedKnowledgeBasesCount'], {
ns: 'appApi',
count: selected.length,
})}
</div>
<div className="flex flex-wrap gap-1.5">
{selected.map((kb) => (
<div
key={kb.id}
className="flex items-center gap-1 rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-2 py-1"
<div className="flex flex-wrap gap-1.5">
{selected.map((kb) => (
<div
key={kb.id}
className="flex items-center gap-1 rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-2 py-1"
>
<span className="max-w-40 truncate system-xs-medium text-text-secondary">
{kb.name}
</span>
<button
type="button"
disabled={isCreating}
aria-label={t(($) => $['operation.remove'], { ns: 'common' })}
className="i-ri-close-line size-3.5 shrink-0 cursor-pointer border-none bg-transparent p-0 text-text-tertiary hover:text-text-secondary"
onClick={() => removeKb(kb.id)}
/>
</div>
))}
</div>
</div>
)}
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
disabled={isCreating}
render={
<Button variant="secondary" size="small" className="w-full">
<span aria-hidden className="mr-1 i-ri-add-line size-4" />
{t(($) => $['apiKeyModal.addKnowledgeBase'], { ns: 'appApi' })}
</Button>
}
/>
<PopoverContent placement="bottom" sideOffset={4} className="w-80 p-2">
<Input
readOnly={isCreating}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t(($) => $['apiKeyModal.searchKnowledgeBases'], {
ns: 'appApi',
})}
/>
<ScrollArea className="mt-2 max-h-60">
<div className="flex flex-col">
{datasets.map((ds) => (
<label
key={ds.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
>
<span className="max-w-40 truncate system-xs-medium text-text-secondary">
{kb.name}
</span>
<button
type="button"
aria-label={t(($) => $['operation.remove'], { ns: 'common' })}
className="i-ri-close-line size-3.5 shrink-0 cursor-pointer border-none bg-transparent p-0 text-text-tertiary hover:text-text-secondary"
onClick={() => removeKb(kb.id)}
<Checkbox
disabled={isCreating}
checked={selectedIds.has(ds.id)}
onCheckedChange={() => toggleKb(ds.id, ds.name)}
/>
</div>
<span className="min-w-0 grow truncate system-sm-regular text-text-secondary">
{ds.name}
</span>
</label>
))}
</div>
</div>
)}
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
render={
<Button variant="secondary" size="small" className="w-full">
<span aria-hidden className="mr-1 i-ri-add-line size-4" />
{t(($) => $['apiKeyModal.addKnowledgeBase'], { ns: 'appApi' })}
</Button>
}
/>
<PopoverContent placement="bottom" sideOffset={4} className="w-80 p-2">
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t(($) => $['apiKeyModal.searchKnowledgeBases'], {
ns: 'appApi',
})}
/>
<ScrollArea className="mt-2 max-h-60">
<div className="flex flex-col">
{datasets.map((ds) => (
<label
key={ds.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
>
<Checkbox
checked={selectedIds.has(ds.id)}
onCheckedChange={() => toggleKb(ds.id, ds.name)}
/>
<span className="min-w-0 grow truncate system-sm-regular text-text-secondary">
{ds.name}
</span>
</label>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
)}
</div>
<div className="flex shrink-0 justify-end gap-2 px-6 pb-6">
<Button variant="secondary" disabled={isCreating} onClick={() => handleOpenChange(false)}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button disabled={!canCreate} loading={isCreating} onClick={handleConfirm}>
{t(($) => $['operation.create'], { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
<div className="flex shrink-0 justify-end gap-2 px-6 pb-6">
<DialogClose disabled={isCreating} render={<Button variant="secondary" />}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</DialogClose>
<Button disabled={!canCreate} loading={isCreating} onClick={handleConfirm}>
{t(($) => $['operation.create'], { ns: 'common' })}
</Button>
</div>
</>
)
}

View File

@@ -1,76 +1,61 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { DynamicOptions, Loader } from 'next/dynamic'
import type { ComponentProps } from 'react'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import AppInfoModals from '../app-info-modals'
vi.mock('next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
const LazyComp = React.lazy(loader)
return function DynamicWrapper(props: Record<string, unknown>) {
return React.createElement(
React.Suspense,
{ fallback: null },
React.createElement(LazyComp, props),
)
}
},
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
vi.mock('@/app/components/app/switch-app-modal', () => ({
default: ({ show, onClose }: { show: boolean; onClose: () => void }) =>
show ? (
<div data-testid="switch-modal">
<button type="button" onClick={onClose}>
Close Switch
</button>
</div>
) : null,
const { loadDynamic, observeImportOpen } = vi.hoisted(() => ({
loadDynamic: vi.fn(),
observeImportOpen: vi.fn(),
}))
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: ({
show,
onHide,
isEditModal,
}: {
show: boolean
onHide: () => void
isEditModal?: boolean
}) =>
show ? (
<div data-testid={isEditModal ? 'edit-modal' : 'create-modal'}>
<button type="button" onClick={onHide}>
Close Edit
</button>
</div>
) : null,
}))
vi.mock('@/app/components/app/duplicate-modal', () => ({
default: ({ show, onHide }: { show: boolean; onHide: () => void }) =>
show ? (
<div data-testid="duplicate-modal">
<button type="button" onClick={onHide}>
Close Dup
</button>
</div>
) : null,
}))
vi.mock('next/dynamic', async (importOriginal) => {
const actual = await importOriginal<typeof import('next/dynamic')>()
return {
...actual,
default: <Props,>(
loader: DynamicOptions<Props> | Loader<Props>,
options?: DynamicOptions<Props>,
) => {
if (typeof loader !== 'function') return actual.default(loader, options)
return actual.default(() => {
loadDynamic()
return loader()
}, options)
},
}
})
vi.mock('@/app/components/workflow/update-dsl-modal', () => ({
default: ({ onCancel, onBackup }: { onCancel: () => void; onBackup: () => void }) => (
<div data-testid="import-dsl-modal">
<button type="button" onClick={onCancel}>
Cancel Import
</button>
<button type="button" onClick={onBackup}>
Backup
</button>
</div>
),
UpdateDSLDialog: ({
open,
onOpenChange,
onBackup,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onBackup: () => void
}) => {
observeImportOpen(open)
return open ? (
<div data-testid="import-dsl-modal">
<button type="button" onClick={() => onOpenChange(false)}>
Cancel Import
</button>
<button type="button" onClick={onBackup}>
Backup
</button>
</div>
) : null
},
}))
vi.mock('@/app/components/app/export-confirm-modal', () => ({
@@ -128,8 +113,8 @@ const defaultProps = {
closeModal: vi.fn(),
secretEnvList: [] as never[],
setSecretEnvList: vi.fn(),
onEdit: vi.fn(),
onCopy: vi.fn(),
onEdit: vi.fn(async () => {}),
onCopy: vi.fn(async () => {}),
onExport: vi.fn(async () => true),
isExporting: false,
exportCheck: vi.fn(),
@@ -146,39 +131,148 @@ describe('AppInfoModals', () => {
vi.clearAllMocks()
})
it('loads the import module only on first activation and retains its closed owner', async () => {
const { rerender } = render(<AppInfoModals {...defaultProps} activeModal={null} />)
expect(loadDynamic).not.toHaveBeenCalled()
expect(observeImportOpen).not.toHaveBeenCalled()
rerender(<AppInfoModals {...defaultProps} activeModal="importDSL" />)
expect(await screen.findByTestId('import-dsl-modal')).toBeInTheDocument()
expect(loadDynamic).toHaveBeenCalledTimes(1)
rerender(<AppInfoModals {...defaultProps} activeModal={null} />)
expect(observeImportOpen).toHaveBeenLastCalledWith(false)
expect(screen.queryByTestId('import-dsl-modal')).not.toBeInTheDocument()
rerender(<AppInfoModals {...defaultProps} activeModal="importDSL" />)
expect(await screen.findByTestId('import-dsl-modal')).toBeInTheDocument()
expect(loadDynamic).toHaveBeenCalledTimes(1)
})
it('should render nothing when activeModal is null', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal={null} />)
})
expect(screen.queryByTestId('switch-modal')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog', { name: 'app.switch' })).not.toBeInTheDocument()
expect(screen.queryByText('app.deleteAppConfirmTitle')).not.toBeInTheDocument()
})
it('should render SwitchAppModal when activeModal is switch', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="switch" />)
})
await waitFor(() => {
expect(screen.getByTestId('switch-modal')).toBeInTheDocument()
})
it('loads the switch module only when its command is first activated', async () => {
const { rerender } = render(<AppInfoModals {...defaultProps} activeModal={null} />)
expect(loadDynamic).not.toHaveBeenCalled()
rerender(<AppInfoModals {...defaultProps} activeModal="switch" />)
expect(await screen.findByRole('dialog', { name: 'app.switch' })).toBeInTheDocument()
expect(loadDynamic).toHaveBeenCalledTimes(1)
rerender(<AppInfoModals {...defaultProps} activeModal={null} />)
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'app.switch' })).not.toBeInTheDocument(),
)
rerender(<AppInfoModals {...defaultProps} activeModal="switch" />)
expect(await screen.findByRole('dialog', { name: 'app.switch' })).toBeInTheDocument()
expect(loadDynamic).toHaveBeenCalledTimes(1)
})
it('should render CreateAppModal in edit mode when activeModal is edit', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="edit" />)
})
await waitFor(() => {
expect(screen.getByTestId('edit-modal')).toBeInTheDocument()
})
it('loads the edit module on first activation and starts a fresh draft after dismissal', async () => {
const user = userEvent.setup()
const { rerender } = render(<AppInfoModals {...defaultProps} activeModal={null} />)
expect(loadDynamic).not.toHaveBeenCalled()
rerender(<AppInfoModals {...defaultProps} activeModal="edit" />)
const dialog = await screen.findByRole('dialog', { name: 'app.editAppTitle' })
expect(loadDynamic).toHaveBeenCalledTimes(1)
const input = within(dialog).getByRole('textbox', { name: 'app.newApp.captionName' })
await user.clear(input)
await user.type(input, 'Discarded edit')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' }))
expect(defaultProps.closeModal).toHaveBeenCalledTimes(1)
expect(defaultProps.onEdit).not.toHaveBeenCalled()
rerender(<AppInfoModals {...defaultProps} activeModal={null} />)
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'app.editAppTitle' })).not.toBeInTheDocument(),
)
rerender(
<AppInfoModals
{...defaultProps}
appDetail={createAppDetail({ name: 'Current app name' })}
activeModal="edit"
/>,
)
const reopened = await screen.findByRole('dialog', { name: 'app.editAppTitle' })
expect(within(reopened).getByRole('textbox', { name: 'app.newApp.captionName' })).toHaveValue(
'Current app name',
)
expect(loadDynamic).toHaveBeenCalledTimes(1)
})
it('should render DuplicateAppModal when activeModal is duplicate', async () => {
await act(async () => {
render(<AppInfoModals {...defaultProps} activeModal="duplicate" />)
})
await waitFor(() => {
expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument()
it('loads the duplicate module on first activation and creates a fresh draft after closing', async () => {
const user = userEvent.setup()
const { rerender } = render(<AppInfoModals {...defaultProps} activeModal={null} />)
expect(loadDynamic).not.toHaveBeenCalled()
rerender(<AppInfoModals {...defaultProps} activeModal="duplicate" />)
const dialog = await screen.findByRole('dialog', { name: 'app.duplicateTitle' })
expect(loadDynamic).toHaveBeenCalledTimes(1)
const input = within(dialog).getByRole('textbox', { name: 'explore.appCustomize.subTitle' })
await user.clear(input)
await user.type(input, 'Unsubmitted copy')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' }))
expect(defaultProps.closeModal).toHaveBeenCalledTimes(1)
rerender(<AppInfoModals {...defaultProps} activeModal={null} />)
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'app.duplicateTitle' })).not.toBeInTheDocument(),
)
rerender(<AppInfoModals {...defaultProps} activeModal="duplicate" />)
const reopened = await screen.findByRole('dialog', { name: 'app.duplicateTitle' })
expect(
within(reopened).getByRole('textbox', { name: 'explore.appCustomize.subTitle' }),
).not.toHaveValue('Unsubmitted copy')
expect(loadDynamic).toHaveBeenCalledTimes(1)
})
it('awaits the copy callback, retains a failed draft, and leaves successful closing to the owner', async () => {
const user = userEvent.setup()
let rejectCopy!: (reason: Error) => void
const onCopy = vi
.fn<ComponentProps<typeof AppInfoModals>['onCopy']>()
.mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectCopy = reject
}),
)
.mockResolvedValue(undefined)
render(<AppInfoModals {...defaultProps} onCopy={onCopy} activeModal="duplicate" />)
const dialog = await screen.findByRole('dialog', { name: 'app.duplicateTitle' })
const input = within(dialog).getByRole('textbox', { name: 'explore.appCustomize.subTitle' })
await user.clear(input)
await user.type(input, 'Retry copy{Enter}')
await waitFor(() => expect(onCopy).toHaveBeenCalledTimes(1))
expect(input).toHaveAttribute('readonly')
await user.keyboard('{Enter}{Escape}')
expect(onCopy).toHaveBeenCalledTimes(1)
expect(defaultProps.closeModal).not.toHaveBeenCalled()
expect(within(dialog).getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled()
await act(async () => rejectCopy(new Error('Copy failed')))
await waitFor(() => expect(input).not.toHaveAttribute('readonly'))
expect(input).toHaveValue('Retry copy')
expect(dialog).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'app.duplicate' }))
await waitFor(() => expect(onCopy).toHaveBeenCalledTimes(2))
await waitFor(() => expect(input).not.toHaveAttribute('readonly'))
expect(onCopy).toHaveBeenLastCalledWith({
name: 'Retry copy',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#FFEAD5',
})
expect(defaultProps.closeModal).not.toHaveBeenCalled()
expect(dialog).toBeInTheDocument()
})
it('should render delete alert dialog when activeModal is delete', async () => {

View File

@@ -3,15 +3,15 @@ import type {
EnvironmentVariableItemResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { AppInfoModalType } from './use-app-info-actions'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { DuplicateAppDialogProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -23,18 +23,24 @@ import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { AppExportConfirmContent } from '@/app/components/app/export-confirm-modal'
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
ssr: false,
})
const SwitchAppDialog = dynamic(
() => import('@/app/components/app/switch-app-modal').then((module) => module.SwitchAppDialog),
{ ssr: false },
)
const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
ssr: false,
})
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
ssr: false,
})
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), {
ssr: false,
})
const DuplicateAppDialog = dynamic(
() => import('@/app/components/app/duplicate-modal').then((module) => module.DuplicateAppDialog),
{ ssr: false },
)
const UpdateDSLDialog = dynamic(
() =>
import('@/app/components/workflow/update-dsl-modal').then((module) => module.UpdateDSLDialog),
{
ssr: false,
},
)
type AppInfoModalsProps = {
appDetail: AppDetailWithSite
@@ -43,7 +49,7 @@ type AppInfoModalsProps = {
secretEnvList: EnvironmentVariableItemResponse[]
setSecretEnvList: (list: EnvironmentVariableItemResponse[]) => void
onEdit: CreateAppModalProps['onConfirm']
onCopy: DuplicateAppModalProps['onConfirm']
onCopy: DuplicateAppDialogProps['onConfirm']
onExport: (include?: boolean) => Promise<boolean>
isExporting: boolean
exportCheck: () => void
@@ -67,6 +73,21 @@ const AppInfoModals = ({
}: AppInfoModalsProps) => {
const { t } = useTranslation(['app', 'common', 'workflow'])
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const [hasActivatedEditDialog, setHasActivatedEditDialog] = useState(() => activeModal === 'edit')
if (activeModal === 'edit' && !hasActivatedEditDialog) setHasActivatedEditDialog(true)
const [hasActivatedDuplicateDialog, setHasActivatedDuplicateDialog] = useState(
() => activeModal === 'duplicate',
)
const [hasActivatedSwitchDialog, setHasActivatedSwitchDialog] = useState(
() => activeModal === 'switch',
)
if (activeModal === 'duplicate' && !hasActivatedDuplicateDialog)
setHasActivatedDuplicateDialog(true)
if (activeModal === 'switch' && !hasActivatedSwitchDialog) setHasActivatedSwitchDialog(true)
const [hasActivatedImportDialog, setHasActivatedImportDialog] = useState(
() => activeModal === 'importDSL',
)
if (activeModal === 'importDSL' && !hasActivatedImportDialog) setHasActivatedImportDialog(true)
const exportConfirmLabelId = React.useId()
const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name
const exportDialogMode =
@@ -98,10 +119,16 @@ const AppInfoModals = ({
return (
<>
{activeModal === 'switch' && (
<SwitchAppModal show sourceApp={appDetail} onClose={closeModal} />
{hasActivatedSwitchDialog && (
<SwitchAppDialog
sourceApp={appDetail}
open={activeModal === 'switch'}
onOpenChange={(open) => {
if (!open) closeModal()
}}
/>
)}
{activeModal === 'edit' && (
{hasActivatedEditDialog && (
<CreateAppModal
isEditModal
appName={appDetail.name}
@@ -113,21 +140,25 @@ const AppInfoModals = ({
appMode={appDetail.mode}
appUseIconAsAnswerIcon={appDetail.use_icon_as_answer_icon}
max_active_requests={appDetail.max_active_requests ?? null}
show
open={activeModal === 'edit'}
onConfirm={onEdit}
onHide={closeModal}
onOpenChange={(open) => {
if (!open) closeModal()
}}
/>
)}
{activeModal === 'duplicate' && (
<DuplicateAppModal
{hasActivatedDuplicateDialog && (
<DuplicateAppDialog
appName={appDetail.name}
icon_type={appDetail.icon_type}
icon={appDetail.icon}
icon_background={appDetail.icon_background}
icon_url={appDetail.icon_url}
show
open={activeModal === 'duplicate'}
onConfirm={onCopy}
onHide={closeModal}
onOpenChange={(open) => {
if (!open) closeModal()
}}
/>
)}
<AlertDialog
@@ -185,22 +216,25 @@ const AppInfoModals = ({
</InputGroup>
</Field>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton type="button">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton type="submit" disabled={isDeleteConfirmDisabled}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
{activeModal === 'importDSL' && (
<UpdateDSLModal
{hasActivatedImportDialog && (
<UpdateDSLDialog
open={activeModal === 'importDSL'}
onOpenChange={(open) => {
if (!open) closeModal()
}}
appId={appDetail.id}
appMode={appDetail.mode}
onCancel={closeModal}
onBackup={exportCheck}
/>
)}
@@ -223,7 +257,7 @@ const AppInfoModals = ({
{t(($) => $['sidebar.exportWarningDesc'], { ns: 'workflow' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
@@ -239,7 +273,7 @@ const AppInfoModals = ({
: t(($) => $['operation.confirm'], { ns: 'common' })}
</span>
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
)
)}

View File

@@ -2,7 +2,7 @@ import type {
AppDetailWithSite,
EnvironmentVariableItemResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { DuplicateAppDialogProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
@@ -194,7 +194,7 @@ export function useAppInfoActions({
[appId, closeModal, t, emitAppMetaUpdate, queryClient],
)
const onCopy: DuplicateAppModalProps['onConfirm'] = useCallback(
const onCopy: DuplicateAppDialogProps['onConfirm'] = useCallback(
async ({ name, icon_type, icon, icon_background }) => {
try {
const newApp = await copyApp({

View File

@@ -136,22 +136,22 @@ vi.mock('@/app/notifications', () => ({
}))
vi.mock('@/app/components/datasets/rename-modal', () => ({
default: ({
show,
onClose,
RenameDatasetModal: ({
open,
onOpenChange,
onSuccess,
}: {
show: boolean
onClose: () => void
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}) => {
if (!show) return null
if (!open) return null
return (
<div data-testid="rename-modal">
<button type="button" onClick={onSuccess}>
Success
</button>
<button type="button" onClick={onClose}>
<button type="button" onClick={() => onOpenChange(false)}>
Close
</button>
</div>

View File

@@ -148,22 +148,22 @@ vi.mock('@/hooks/use-knowledge', () => ({
}))
vi.mock('@/app/components/datasets/rename-modal', () => ({
default: ({
show,
onClose,
RenameDatasetModal: ({
open,
onOpenChange,
onSuccess,
}: {
show: boolean
onClose: () => void
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}) => {
if (!show) return null
if (!open) return null
return (
<div data-testid="rename-modal">
<button type="button" onClick={onSuccess}>
Success
</button>
<button type="button" onClick={onClose}>
<button type="button" onClick={() => onOpenChange(false)}>
Close
</button>
</div>

View File

@@ -1,11 +1,11 @@
import type { DataSet } from '@/models/datasets'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
@@ -34,7 +34,7 @@ import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { downloadBlob } from '@/utils/download'
import { getDatasetACLCapabilities } from '@/utils/permission'
import RenameDatasetModal from '../../datasets/rename-modal'
import { RenameDatasetModal } from '../../datasets/rename-modal'
type DropDownProps = {
expand: boolean
@@ -217,14 +217,12 @@ const DropDown = ({ expand, triggerClassName }: DropDownProps) => {
</>
)}
</DropdownMenuContent>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
dataset={dataset!}
onClose={() => setShowRenameModal(false)}
onSuccess={refreshDataset}
/>
)}
<RenameDatasetModal
open={showRenameModal}
dataset={dataset!}
onOpenChange={setShowRenameModal}
onSuccess={refreshDataset}
/>
<AlertDialog
open={showConfirmDelete}
onOpenChange={(open) => !open && setShowConfirmDelete(false)}
@@ -238,14 +236,14 @@ const DropDown = ({ expand, triggerClassName }: DropDownProps) => {
{confirmMessage}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirmDelete}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenu>

View File

@@ -3,11 +3,11 @@
import type { SnippetDetail } from '@/models/snippet'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import {
@@ -196,7 +196,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
{t(($) => $.deleteConfirmContent)}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogFooter className="pt-0">
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
@@ -206,7 +206,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
>
{t(($) => $['menu.deleteSnippet'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>

View File

@@ -57,9 +57,8 @@ vi.mock('@/service/use-workflow', () => ({
vi.mock('../shared/use-access-point-actions', () => ({
useAccessPointActions: () => ({
handleResult: vi.fn(),
refreshAppDetail: vi.fn(),
saveSiteConfig: vi.fn(),
saveSiteConfig: vi.fn().mockResolvedValue(true),
}),
}))

View File

@@ -1,15 +1,19 @@
import type { ReactElement } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { toast } from '@/app/notifications'
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { render } from '@/test/console/render'
import { createAppSiteFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { EnvironmentServiceApiCard } from '../deployed-environment-access-points/environment-service-api-card'
import { EnvironmentWebAppCard } from '../deployed-environment-access-points/environment-web-app-card'
const mocks = vi.hoisted(() => ({
getApi: vi.fn(),
saveSiteConfig: vi.fn(),
getSite: vi.fn(),
getSubjects: vi.fn(),
resetSite: vi.fn(),
@@ -45,6 +49,7 @@ vi.mock('@/app/components/app/store', () => ({
icon_type: 'emoji',
icon_url: null,
site: {
...createAppSiteFixture(),
access_token: 'built-in-code',
app_base_url: 'https://built-in.example.test',
},
@@ -58,24 +63,10 @@ vi.mock('@/app/components/base/app-icon', () => ({
vi.mock('@/app/components/app/access-point/shared/use-access-point-actions', () => ({
useAccessPointActions: () => ({
saveSiteConfig: vi.fn(),
saveSiteConfig: mocks.saveSiteConfig,
}),
}))
vi.mock('@/app/components/app/overview/customize', () => ({
default: ({ api_base_url, isShow }: { api_base_url: string; isShow: boolean }) =>
isShow ? (
<div role="dialog" aria-label="environment customize">
{api_base_url}
</div>
) : null,
}))
vi.mock('@/app/components/app/overview/settings', () => ({
default: ({ isShow }: { isShow: boolean }) =>
isShow ? <div role="dialog" aria-label="environment settings" /> : null,
}))
vi.mock('../deployed-environment-access-points/environment-access-control', () => ({
EnvironmentAccessControl: (props: {
appId: string
@@ -131,13 +122,19 @@ const api = {
}
function renderCard(ui: ReactElement, queryClient = createTestQueryClient()) {
seedFeatures(queryClient)
seedSystemFeatures(queryClient)
queryClient.setQueryData(['system-features'], {
webapp_auth: {
enabled: true,
},
})
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
return render(
<NuqsTestingAdapter>
<QueryClientTestProvider queryClient={queryClient}>{ui}</QueryClientTestProvider>
</NuqsTestingAdapter>,
)
}
function createDeferredPromise<T>() {
@@ -157,6 +154,7 @@ afterAll(() => vi.unstubAllGlobals())
describe('environment access point cards', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.saveSiteConfig.mockResolvedValue(true)
mockAppMode = 'workflow'
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
@@ -409,12 +407,27 @@ describe('environment access point cards', () => {
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeEnabled(),
)
await user.click(screen.getByRole('button', { name: /customize\.entry/ }))
expect(screen.getByRole('dialog', { name: 'environment customize' })).toHaveTextContent(
'https://api.example.test/v1',
)
expect(
screen.getByRole('dialog', { name: 'appOverview.overview.appInfo.customize.title' }),
).toHaveTextContent('https://api.example.test/v1')
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: /settings\.settings/ }))
expect(screen.getByRole('dialog', { name: 'environment settings' })).toBeInTheDocument()
const settings = await screen.findByRole('dialog', {
name: 'appOverview.overview.appInfo.settings.title',
})
await user.clear(within(settings).getByPlaceholderText('app.appNamePlaceholder'))
await user.type(
within(settings).getByPlaceholderText('app.appNamePlaceholder'),
'Environment portal',
)
await user.click(within(settings).getByRole('button', { name: 'common.operation.save' }))
expect(mocks.saveSiteConfig).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Environment portal' }),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('keeps view actions available while disabling deployed Web App management', async () => {

View File

@@ -1,7 +1,7 @@
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
import type { PublishedWorkflow } from '../shared/utils'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
@@ -12,13 +12,21 @@ import { AppModeEnum } from '@/types/app'
import { MCPAccessPointCard } from '../built-in-access-points/mcp-card'
const mocks = vi.hoisted(() => ({
invalidateServerDetail: vi.fn(),
getServer: vi.fn(),
createServer: vi.fn(),
saveServer: vi.fn(),
serverDetail: {
data: undefined as undefined | { id: string; server_code: string; status: string },
data: undefined as
| undefined
| {
id: string
server_code: string
status: string
description?: string
parameters?: Record<string, string>
},
isPending: false,
},
modalProps: vi.fn(),
refreshServerCode: vi.fn(),
updateServer: vi.fn(),
}))
@@ -29,37 +37,19 @@ vi.mock('@/app/notifications', () => ({
},
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
apps: {
byAppId: {
server: {
put: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateServer,
...options,
}),
},
},
},
vi.mock('@/service/console/browser', () => ({
consoleBrowserLink: {
call: (path: string[], input: unknown) => {
if (path.join('.') === 'apps.byAppId.server.put') return mocks.updateServer(input)
throw new Error(`Unexpected console request: ${path.join('.')}`)
},
},
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateMCPServerDetail: () => mocks.invalidateServerDetail,
useMCPServerDetail: () => mocks.serverDetail,
useRefreshMCPServerCode: () => ({
isPending: false,
mutateAsync: mocks.refreshServerCode,
}),
}))
vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({
default: (props: Record<string, unknown>) => {
mocks.modalProps(props)
return <div role="dialog" aria-label="MCP server settings" />
},
vi.mock('@/service/base', () => ({
get: (url: string) => mocks.getServer(url),
post: (url: string, options: unknown) => mocks.createServer(url, options),
put: (url: string, options: unknown) => mocks.saveServer(url, options),
}))
const appInfo = createAppDetailFixture({
@@ -113,7 +103,7 @@ function createDeferredPromise<T>() {
function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: PublishedWorkflow) {
const queryClient = createTestQueryClient()
return render(
const result = render(
<QueryClientProvider client={queryClient}>
<MCPAccessPointCard
appInfo={cardAppInfo}
@@ -124,6 +114,7 @@ function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: Publish
/>
</QueryClientProvider>,
)
return { ...result, queryClient }
}
describe('MCPAccessPointCard', () => {
@@ -132,6 +123,13 @@ describe('MCPAccessPointCard', () => {
mocks.serverDetail.data = undefined
mocks.serverDetail.isPending = false
mocks.updateServer.mockResolvedValue(undefined)
mocks.getServer.mockImplementation(() =>
mocks.serverDetail.isPending
? new Promise(() => {})
: Promise.resolve(mocks.serverDetail.data ?? {}),
)
mocks.createServer.mockResolvedValue({})
mocks.saveServer.mockResolvedValue({})
})
afterEach(() => {
@@ -146,21 +144,11 @@ describe('MCPAccessPointCard', () => {
renderCard()
await user.click(screen.getByRole('button', { name: /addDescription/ }))
const configure = screen.getByRole('button', { name: /addDescription/ })
await waitFor(() => expect(configure).toBeEnabled())
await user.click(configure)
expect(screen.getByRole('dialog', { name: 'MCP server settings' })).toBeInTheDocument()
expect(mocks.modalProps).toHaveBeenCalledWith(
expect.objectContaining({
latestParams: [
{
label: 'Question',
required: true,
type: 'text-input',
variable: 'question',
},
],
}),
)
expect(screen.getByRole('textbox', { name: 'Question' })).toBeInTheDocument()
expect(fetchSpy).not.toHaveBeenCalled()
})
@@ -169,13 +157,11 @@ describe('MCPAccessPointCard', () => {
renderCard(workflowAppInfo, publishedWorkflow)
await user.click(screen.getByRole('button', { name: /addDescription/ }))
const configure = screen.getByRole('button', { name: /addDescription/ })
await waitFor(() => expect(configure).toBeEnabled())
await user.click(configure)
expect(mocks.modalProps).toHaveBeenCalledWith(
expect.objectContaining({
latestParams: [{ label: 'Query', variable: 'query' }],
}),
)
expect(screen.getByRole('textbox', { name: 'Query' })).toBeInTheDocument()
})
it('shows loading without reporting an environment failure', () => {
@@ -202,7 +188,8 @@ describe('MCPAccessPointCard', () => {
mocks.updateServer.mockReturnValueOnce(toggle.promise)
renderCard()
const accessSwitch = screen.getByRole('switch')
const accessSwitch = await screen.findByRole('switch')
await waitFor(() => expect(accessSwitch).toHaveAttribute('aria-checked', 'true'))
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
@@ -230,7 +217,8 @@ describe('MCPAccessPointCard', () => {
.mockReturnValueOnce(secondToggle.promise)
renderCard()
const accessSwitch = screen.getByRole('switch')
const accessSwitch = await screen.findByRole('switch')
await waitFor(() => expect(accessSwitch).toHaveAttribute('aria-checked', 'true'))
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
@@ -250,8 +238,86 @@ describe('MCPAccessPointCard', () => {
secondToggle.resolve()
await waitFor(() => {
expect(mocks.invalidateServerDetail).toHaveBeenCalledTimes(2)
expect(mocks.getServer).toHaveBeenCalledTimes(3)
})
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
})
it('opens first-time configuration from the switch without changing server status', async () => {
const user = userEvent.setup()
renderCard()
const configure = screen.getByRole('button', { name: /addDescription/ })
await waitFor(() => expect(configure).toBeEnabled())
const accessSwitch = await screen.findByRole('switch')
await user.click(accessSwitch)
expect(
screen.getByRole('dialog', { name: 'tools.mcp.server.modal.addTitle' }),
).toBeInTheDocument()
expect(mocks.updateServer).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'tools.mcp.modal.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
})
it('keeps pending and failed creation open, retries its exact draft, and closes without awaiting refresh', async () => {
const user = userEvent.setup()
const pending = createDeferredPromise<unknown>()
const refresh = createDeferredPromise<unknown>()
mocks.createServer.mockReturnValueOnce(pending.promise)
const { queryClient } = renderCard()
const configure = screen.getByRole('button', { name: /addDescription/ })
await waitFor(() => expect(configure).toBeEnabled())
await user.click(configure)
const description = screen.getByRole('textbox', { name: 'tools.mcp.server.modal.description' })
const parameter = screen.getByRole('textbox', { name: 'Question' })
await user.clear(description)
await user.type(description, 'Server draft')
await user.type(parameter, 'Question hint')
const submit = screen.getByRole('button', { name: 'tools.mcp.server.modal.confirm' })
await user.click(submit)
await waitFor(() => expect(submit).toHaveAttribute('aria-disabled', 'true'))
expect(description).toHaveAttribute('readonly')
expect(parameter).toHaveAttribute('readonly')
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'tools.mcp.modal.cancel' })).toBeDisabled()
await user.keyboard('{Escape}')
await user.click(submit)
expect(mocks.createServer).toHaveBeenCalledTimes(1)
await act(async () => pending.reject(new Error('Save failed')))
await waitFor(() => expect(submit).not.toHaveAttribute('aria-disabled', 'true'))
expect(description).toHaveValue('Server draft')
mocks.getServer.mockReturnValue(refresh.promise)
await user.click(submit)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(mocks.createServer).toHaveBeenNthCalledWith(2, 'apps/app-1/server', {
body: { description: 'Server draft', parameters: { question: 'Question hint' } },
})
expect(queryClient.isFetching()).toBeGreaterThan(0)
await act(async () =>
refresh.resolve({ id: 'server-1', status: 'active', server_code: 'server-code' }),
)
})
it('updates existing configuration with the server id and only current parameter values', async () => {
mocks.serverDetail.data = {
id: 'server-1',
server_code: 'server-code',
status: 'active',
description: 'Existing',
parameters: { question: 'Old hint', removed: 'Do not send' },
}
const user = userEvent.setup()
renderCard()
await user.click(await screen.findByRole('button', { name: 'tools.mcp.server.edit' }))
const description = screen.getByRole('textbox', { name: 'tools.mcp.server.modal.description' })
const parameter = screen.getByRole('textbox', { name: 'Question' })
await user.clear(description)
await user.type(description, 'Updated')
await user.clear(parameter)
await user.click(screen.getByRole('button', { name: 'tools.mcp.modal.save' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(mocks.saveServer).toHaveBeenCalledWith('apps/app-1/server', {
body: { id: 'server-1', description: 'Updated', parameters: { question: '' } },
})
expect(mocks.createServer).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,146 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import { MCPAccessPointCard } from '../built-in-access-points/mcp-card'
const { transport } = vi.hoisted(() => ({ transport: vi.fn() }))
vi.mock('@/service/console/browser', () => ({ consoleBrowserLink: { call: transport } }))
const clients: QueryClient[] = []
const appInfo = createAppDetailFixture({
id: 'mcp-app',
description: 'App description',
api_base_url: 'https://api.example.test/v1',
mode: AppModeEnum.CHAT,
model_config: createAppModelConfigFixture({
updated_at: 1_710_000_000,
user_input_form: [
{ 'text-input': { label: 'Question', required: true, variable: 'question' } },
],
}),
})
beforeEach(async () => {
await page.viewport(1280, 900)
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
transport.mockReset()
transport.mockImplementation((path: string[]) => {
throw new Error(`Unexpected Console request: ${path.join('.')}`)
})
})
afterEach(() => {
clients.splice(0).forEach((client) => client.clear())
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it.each(['first enable', 'configure'] as const)(
'preserves the %s draft through exit, returns focus to its real entry, and reopens from fresh source',
async (entryKind) => {
let detail =
entryKind === 'configure'
? {
id: 'server-1',
server_code: 'code',
status: 'active',
description: 'Published description',
parameters: { question: 'Published hint' },
}
: {}
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (!url.endsWith('/apps/mcp-app/server') || (init?.method && init.method !== 'GET'))
throw new Error(`Unexpected request: ${init?.method} ${url}`)
return Response.json(detail)
})
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
})
clients.push(client)
const screen = await render(
<QueryClientProvider client={client}>
<MCPAccessPointCard
appInfo={appInfo}
canManageAccessPoint
triggerModeDisabled={false}
workflow={undefined}
workflowLoading={false}
/>
</QueryClientProvider>,
)
const configure = screen.getByRole('button', {
name: entryKind === 'configure' ? 'tools.mcp.server.edit' : 'tools.mcp.server.addDescription',
})
await expect.element(configure).toBeEnabled()
const entry = entryKind === 'first enable' ? screen.getByRole('switch') : configure
;(entry.element() as HTMLElement).focus()
await userEvent.keyboard('{Space}')
const dialog = screen.getByRole('dialog', {
name:
entryKind === 'configure'
? 'tools.mcp.server.modal.editTitle'
: 'tools.mcp.server.modal.addTitle',
})
await expect.element(dialog).toBeVisible()
const description = dialog.getByRole('textbox', { name: 'tools.mcp.server.modal.description' })
const parameter = dialog.getByRole('textbox', { name: 'Question' })
await description.fill('Unsaved description')
await description.click()
await userEvent.keyboard('{End}{Enter}second line')
await expect.element(description).toHaveValue('Unsaved description\nsecond line')
await parameter.fill('Unsaved hint')
expect(transport).not.toHaveBeenCalled()
const popup = dialog.element()
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
const exitingDraft = new Promise<boolean>((resolve) => {
const onTransition = (event: Event) => {
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
popup.removeEventListener('transitionrun', onTransition)
const fields = popup.querySelectorAll('textarea')
resolve(
popup.isConnected &&
fields[0]?.value === 'Unsaved description\nsecond line' &&
fields[1]?.value === 'Unsaved hint',
)
}
popup.addEventListener('transitionrun', onTransition)
})
await dialog
.getByRole('button', {
name: entryKind === 'first enable' ? 'tools.mcp.modal.cancel' : 'common.operation.close',
})
.click()
expect(await exitingDraft).toBe(true)
await expect.poll(() => popup.isConnected).toBe(false)
await expect.element(entry).toHaveFocus()
if (entryKind === 'first enable')
await expect.element(entry).toHaveAttribute('aria-checked', 'false')
detail = {
id: 'server-1',
server_code: 'code',
status: 'active',
description: 'Fresh description',
parameters: { question: 'Fresh hint' },
}
await client.invalidateQueries()
const edit = screen.getByRole('button', { name: 'tools.mcp.server.edit' })
await edit.click()
const reopened = screen.getByRole('dialog', { name: 'tools.mcp.server.modal.editTitle' })
await expect
.element(reopened.getByRole('textbox', { name: 'tools.mcp.server.modal.description' }))
.toHaveValue('Fresh description')
await expect
.element(reopened.getByRole('textbox', { name: 'Question' }))
.toHaveValue('Fresh hint')
await userEvent.keyboard('{Escape}')
await expect.element(reopened).not.toBeInTheDocument()
await expect.element(edit).toHaveFocus()
expect(fetchSpy).toHaveBeenCalled()
},
)

View File

@@ -3,45 +3,30 @@ import type {
AppSiteUpdatePayload,
} from '@dify/contracts/api/console/apps/types.gen'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { consoleQuery } from '@/service/console'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
import { useAccessPointActions } from '../shared/use-access-point-actions'
const mocks = vi.hoisted(() => ({
fetchAppDetail: vi.fn(),
setAppDetail: vi.fn(),
toast: vi.fn(),
updateAppSiteConfig: vi.fn(),
}))
vi.mock('@/app/notifications', () => ({ toast: mocks.toast }))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { setAppDetail: typeof mocks.setAppDetail }) => unknown) =>
selector({ setAppDetail: mocks.setAppDetail }),
}))
vi.mock('@/service/console', () => ({
consoleClient: {
apps: { byAppId: { get: mocks.fetchAppDetail, site: { post: mocks.updateAppSiteConfig } } },
},
consoleQuery: {
apps: {
get: { key: () => ['apps'] },
recent: { get: { key: () => ['apps', 'recent'] } },
starred: { get: { key: () => ['apps', 'starred'] } },
byAppId: {
get: {
queryKey: ({ input }: { input: { params: { app_id: string } } }) => [
'app-detail',
input.params.app_id,
],
},
},
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
return {
...actual,
consoleClient: {
apps: { byAppId: { get: mocks.fetchAppDetail, site: { post: mocks.updateAppSiteConfig } } },
},
},
}))
}
})
const siteConfig = {
chat_color_theme: '#000000',
@@ -69,10 +54,23 @@ function renderActions(appId = 'app-1', canManageAccessPoint = true) {
return { ...rendered, queryClient }
}
function detailKey(appId = 'app-1') {
return consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: appId } } })
}
function deferredDetail() {
let resolve!: (value: ReturnType<typeof createAppDetailFixture>) => void
const promise = new Promise<ReturnType<typeof createAppDetailFixture>>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('useAccessPointActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.fetchAppDetail.mockResolvedValue(createAppDetailFixture())
useAppStore.getState().setAppDetail(createAppDetailFixture({ id: 'app-1' }))
mocks.fetchAppDetail.mockResolvedValue(createAppDetailFixture({ id: 'app-1' }))
mocks.updateAppSiteConfig.mockResolvedValue({
app_id: 'app-1',
customize_token_strategy: 'not_allow',
@@ -84,57 +82,86 @@ describe('useAccessPointActions', () => {
} satisfies AppSiteResponse)
})
it('refreshes after a successful access point result', async () => {
const { result } = renderActions()
afterEach(() => useAppStore.getState().setAppDetail(undefined))
act(() => result.current.handleResult(null))
await waitFor(() => {
expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ params: { app_id: 'app-1' } })
expect(mocks.setAppDetail).toHaveBeenCalledWith(createAppDetailFixture())
it('succeeds after POST while detail refresh continues in the background', async () => {
const detail = deferredDetail()
mocks.fetchAppDetail.mockReturnValueOnce(detail.promise)
const updated = createAppDetailFixture({
id: 'app-1',
site: createAppSiteFixture({ title: 'Saved portal' }),
})
expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully', {
type: 'success',
})
})
it('reports a failed result without refreshing stale state', () => {
const { result } = renderActions()
act(() => result.current.handleResult(new Error('request failed')))
expect(mocks.fetchAppDetail).not.toHaveBeenCalled()
expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully', {
type: 'error',
})
})
it('invalidates app detail and lists after saving site configuration', async () => {
const { queryClient, result } = renderActions()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
await act(async () => {
await result.current.saveSiteConfig(siteConfig)
})
const previous = useAppStore.getState().appDetail
queryClient.setQueryData(detailKey(), previous)
expect(await result.current.saveSiteConfig(siteConfig)).toBe(true)
expect(mocks.updateAppSiteConfig).toHaveBeenCalledWith({
params: { app_id: 'app-1' },
body: siteConfig,
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['app-detail', 'app-1'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'starred'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'recent'] })
await waitFor(() => expect(mocks.setAppDetail).toHaveBeenCalledWith(createAppDetailFixture()))
})
it('keeps site configuration behind Access Point management permission', async () => {
const { result } = renderActions('app-1', false)
await act(async () => {
await result.current.saveSiteConfig(siteConfig)
expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ params: { app_id: 'app-1' } })
expect(useAppStore.getState().appDetail).toEqual(previous)
expect(queryClient.getQueryState(detailKey())?.isInvalidated).toBe(true)
expect(mocks.toast).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedSuccessfully', {
type: 'success',
})
await act(async () => detail.resolve(updated))
await waitFor(() => expect(useAppStore.getState().appDetail).toEqual(updated))
})
it('returns false after POST failure and allows retrying without refreshing failed saves', async () => {
const { result } = renderActions()
const previous = useAppStore.getState().appDetail
mocks.updateAppSiteConfig.mockRejectedValueOnce(new Error('Save failed'))
expect(await result.current.saveSiteConfig(siteConfig)).toBe(false)
expect(useAppStore.getState().appDetail).toEqual(previous)
expect(mocks.fetchAppDetail).not.toHaveBeenCalled()
expect(mocks.toast).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedUnsuccessfully', {
type: 'error',
})
expect(await result.current.saveSiteConfig(siteConfig)).toBe(true)
})
it('keeps a successful save successful when the background detail refresh fails', async () => {
const { result } = renderActions()
const previous = useAppStore.getState().appDetail
const error = new Error('Refresh failed')
mocks.fetchAppDetail.mockRejectedValueOnce(error)
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(await result.current.saveSiteConfig(siteConfig)).toBe(true)
await waitFor(() =>
expect(errorLog).toHaveBeenCalledWith('Failed to refresh app detail:', error),
)
expect(useAppStore.getState().appDetail).toEqual(previous)
expect(mocks.toast).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedSuccessfully', {
type: 'success',
})
errorLog.mockRestore()
})
it('returns false without a request when management permission is denied', async () => {
const { result } = renderActions('app-1', false)
expect(await result.current.saveSiteConfig(siteConfig)).toBe(false)
expect(mocks.updateAppSiteConfig).not.toHaveBeenCalled()
expect(mocks.fetchAppDetail).not.toHaveBeenCalled()
expect(mocks.toast).not.toHaveBeenCalled()
})
it('preserves the separate token refresh callback contract', async () => {
const { result } = renderActions()
const updated = createAppDetailFixture({ id: 'app-1', name: 'Refreshed application' })
mocks.fetchAppDetail.mockResolvedValueOnce(updated)
await act(async () => result.current.refreshAppDetail())
expect(useAppStore.getState().appDetail).toEqual(updated)
const error = new Error('Token refresh failed')
mocks.fetchAppDetail.mockRejectedValueOnce(error)
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
await expect(result.current.refreshAppDetail()).resolves.toBeUndefined()
expect(errorLog).toHaveBeenCalledWith('Failed to refresh app detail:', error)
errorLog.mockRestore()
})
})

View File

@@ -8,6 +8,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
import { AccessMode } from '@/models/access-control'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { render } from '@/test/console/render'
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
import { createTestQueryClient } from '@/test/query-client'
@@ -28,32 +29,37 @@ vi.mock('@/app/notifications', () => ({
},
}))
vi.mock('@/service/console', () => ({
consoleQuery: {
apps: {
byAppId: {
siteEnable: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.siteEnable,
...options,
}),
},
},
site: {
accessTokenReset: {
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
return {
...actual,
consoleQuery: {
account: actual.consoleQuery.account,
apps: {
byAppId: {
siteEnable: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.resetSiteAccessToken,
mutationFn: mocks.siteEnable,
...options,
}),
},
},
site: {
accessTokenReset: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.resetSiteAccessToken,
...options,
}),
},
},
},
},
},
},
},
}))
}
})
vi.mock('@/service/access-control/use-app-access-control', async (importOriginal) => {
const actual =
@@ -88,27 +94,8 @@ vi.mock('@/app/components/app/app-access-control', () => ({
),
}))
vi.mock('@/app/components/app/overview/customize', () => ({
default: () => null,
}))
vi.mock('@/app/components/app/overview/settings', () => ({
default: () => null,
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: ({
hiddenInputs = [],
isShow,
}: {
hiddenInputs?: Array<{ variable: string }>
isShow: boolean
}) =>
isShow ? (
<div role="dialog" aria-label="embed into site">
{hiddenInputs.map((input) => input.variable).join(',')}
</div>
) : null,
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
function createAppInfo(mode: AppModeEnum): AppDetailWithSite {
@@ -151,6 +138,7 @@ function renderCard(
appDetail: { ...createAppInfo(mode), ...appOverrides, access_mode: accessMode },
})
const queryClient = createTestQueryClient()
seedAccountProfileQuery(queryClient)
queryClient.setQueryData(['system-features'], {
webapp_auth: { enabled: showAccessControl },
})
@@ -192,7 +180,7 @@ function StoreConnectedWebAppCard({
canManageAccessPoint={canManageAccessPoint}
showAccessControl={showAccessControl}
onRefreshApp={onRefreshApp}
onSaveSiteConfig={vi.fn().mockResolvedValue(undefined)}
onSaveSiteConfig={vi.fn().mockResolvedValue(true)}
workflow={workflow}
/>
)
@@ -403,7 +391,9 @@ describe('WebAppAccessPointCard', () => {
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
expect(screen.getByRole('dialog', { name: 'embed into site' })).toBeInTheDocument()
expect(
screen.getByRole('dialog', { name: 'appOverview.overview.appInfo.embedded.title' }),
).toBeInTheDocument()
})
it('updates site status through the generated contract', async () => {
@@ -464,7 +454,8 @@ describe('WebAppAccessPointCard', () => {
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
expect(screen.getByRole('dialog', { name: 'embed into site' })).toHaveTextContent('secret')
await user.click(screen.getByRole('button', { name: /hiddenInputs.title/ }))
expect(screen.getByLabelText('Secret')).toBeInTheDocument()
})
it('configures hidden workflow inputs before opening the Web App', async () => {

View File

@@ -4,11 +4,11 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.g
import type { PublishedWorkflow } from '../shared/utils'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -17,7 +17,7 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessPointCard } from '@/app/components/base/access-point/card'
import { AccessPointUrl } from '@/app/components/base/access-point/url'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { MCPServerModal } from '@/app/components/tools/mcp/mcp-server-modal'
import { BlockEnum } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
import { consoleQuery } from '@/service/console'
@@ -199,19 +199,17 @@ export function MCPAccessPointCard({
/>
</AccessPointCard>
{showServerModal && (
<MCPServerModal
show
appID={appInfo.id}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={() => {
setShowServerModal(false)
invalidateServerDetail(appInfo.id)
}}
appInfo={appInfo}
/>
)}
<MCPServerModal
open={showServerModal}
appID={appInfo.id}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onOpenChange={(open) => {
setShowServerModal(open)
if (!open) invalidateServerDetail(appInfo.id)
}}
appInfo={appInfo}
/>
<AlertDialog open={showRegenerate} onOpenChange={(open) => !open && setShowRegenerate(false)}>
<AlertDialogContent>
@@ -223,14 +221,14 @@ export function MCPAccessPointCard({
{t(($) => $['mcp.server.reGen'], { ns: 'tools' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => void handleRegenerate()}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>

View File

@@ -9,11 +9,11 @@ import type { PublishedWorkflow } from '../shared/utils'
import type { AccessPointAvailability } from '@/app/components/base/access-point/status'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -21,9 +21,9 @@ import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccessControl from '@/app/components/app/app-access-control'
import CustomizeModal from '@/app/components/app/overview/customize'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import SettingsModal from '@/app/components/app/overview/settings'
import { CustomizeDialog } from '@/app/components/app/overview/customize'
import { EmbeddedDialog } from '@/app/components/app/overview/embedded'
import { SettingsDialog } from '@/app/components/app/overview/settings'
import { WorkflowLaunchDialog } from '@/app/components/app/overview/workflow-launch-dialog'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AccessPointCard } from '@/app/components/base/access-point/card'
@@ -67,7 +67,7 @@ type WebAppAccessPointCardProps = {
highlighted?: boolean
showAccessControl: boolean
onRefreshApp: () => Promise<void>
onSaveSiteConfig: (params: AppSiteUpdatePayload) => Promise<void>
onSaveSiteConfig: (params: AppSiteUpdatePayload) => Promise<boolean>
workflow: PublishedWorkflow
}
@@ -91,9 +91,6 @@ export function WebAppAccessPointCard({
'navigation',
])
const setAppDetail = useAppStore((state) => state.setAppDetail)
const [showSettings, setShowSettings] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomize, setShowCustomize] = useState(false)
const [showAccess, setShowAccess] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const [showWorkflowLaunch, setShowWorkflowLaunch] = useState(false)
@@ -219,36 +216,30 @@ export function WebAppAccessPointCard({
</Button>
)}
{supportsEmbedded && (
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
<EmbeddedDialog
siteInfo={site ?? undefined}
appBaseUrl={site?.app_base_url}
accessToken={site?.access_token ?? undefined}
hiddenInputs={hiddenLaunchVariables}
disabled={!actionsAvailable || !canManageAccessPoint}
onClick={() => setShowEmbedded(true)}
>
<span aria-hidden className="i-ri-window-line size-4" />
{t(($) => $['studio.accessPoint.embedIntoSite'], { ns: 'deployments' })}
</Button>
/>
)}
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
<CustomizeDialog
appId={appInfo.id}
api_base_url={appInfo.api_base_url}
mode={appInfo.mode}
disabled={!actionsAvailable || !canManageAccessPoint}
onClick={() => setShowCustomize(true)}
>
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
{t(($) => $['overview.appInfo.customize.entry'], {
ns: 'appOverview',
})}
</Button>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
/>
<SettingsDialog
key={appInfo.id}
isChat={
appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW
}
canDeploy={canDeploy}
appInfo={site ? { id: appInfo.id, mode: appInfo.mode, site } : undefined}
disabled={siteAvailability !== 'available' || !canManageAccessPoint}
onClick={() => setShowSettings(true)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t(($) => $['settings.settings'], { ns: 'navigation' })}
</Button>
onSave={onSaveSiteConfig}
/>
</>
}
>
@@ -290,33 +281,6 @@ export function WebAppAccessPointCard({
))}
</AccessPointCard>
{site && (
<SettingsModal
isChat={appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW}
canDeploy={canDeploy}
appInfo={{ id: appInfo.id, mode: appInfo.mode, site }}
isShow={showSettings}
onClose={() => setShowSettings(false)}
onSave={onSaveSiteConfig}
/>
)}
{supportsEmbedded && site?.access_token && (
<EmbeddedModal
siteInfo={site}
isShow={showEmbedded}
onClose={() => setShowEmbedded(false)}
appBaseUrl={site.app_base_url}
accessToken={site.access_token}
hiddenInputs={hiddenLaunchVariables}
/>
)}
<CustomizeModal
isShow={showCustomize}
onClose={() => setShowCustomize(false)}
appId={appInfo.id}
api_base_url={appInfo.api_base_url}
mode={appInfo.mode}
/>
{showAccess && (
<AccessControl
app={appInfo}
@@ -344,7 +308,7 @@ export function WebAppAccessPointCard({
{t(($) => $['overview.appInfo.regenerateNotice'], { ns: 'appOverview' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
@@ -354,7 +318,7 @@ export function WebAppAccessPointCard({
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>

View File

@@ -2,19 +2,18 @@
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CustomizeModal from '@/app/components/app/overview/customize'
import SettingsModal from '@/app/components/app/overview/settings'
import { CustomizeDialog } from '@/app/components/app/overview/customize'
import { SettingsDialog } from '@/app/components/app/overview/settings'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AccessPointCard } from '@/app/components/base/access-point/card'
import { AccessPointUrl } from '@/app/components/base/access-point/url'
@@ -64,8 +63,6 @@ export function EnvironmentWebAppCard({
const appInfo = useAppStore((state) => state.appDetail)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const actions = useAccessPointActions(appId, canManageAccessPoint)
const [showSettings, setShowSettings] = useState(false)
const [showCustomize, setShowCustomize] = useState(false)
const [showAccess, setShowAccess] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const params = {
@@ -189,26 +186,24 @@ export function EnvironmentWebAppCard({
onEnabledChange={siteQuery.isSuccess ? handleEnabledChange : undefined}
actions={
<>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
<CustomizeDialog
appId={appId}
api_base_url={apiQuery.data?.base_url ?? ''}
mode={appInfo?.mode}
disabled={!actionsAvailable || !apiQuery.isSuccess || !canManageAccessPoint}
onClick={() => setShowCustomize(true)}
>
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
{t(($) => $['overview.appInfo.customize.entry'], {
ns: 'appOverview',
})}
</Button>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!appInfo?.site || !siteQuery.isSuccess || !canManageAccessPoint}
onClick={() => setShowSettings(true)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t(($) => $['settings.settings'], { ns: 'navigation' })}
</Button>
/>
<SettingsDialog
key={appId}
isChat={false}
canDeploy
appInfo={
appInfo?.site
? { id: appInfo.id, mode: appInfo.mode, site: appInfo.site }
: undefined
}
disabled={!siteQuery.isSuccess || !canManageAccessPoint}
onSave={actions.saveSiteConfig}
/>
</>
}
>
@@ -247,23 +242,6 @@ export function EnvironmentWebAppCard({
))}
</AccessPointCard>
{appInfo?.site && (
<SettingsModal
isChat={false}
canDeploy
appInfo={{ id: appInfo.id, mode: appInfo.mode, site: appInfo.site }}
isShow={showSettings}
onClose={() => setShowSettings(false)}
onSave={actions.saveSiteConfig}
/>
)}
<CustomizeModal
isShow={showCustomize}
onClose={() => setShowCustomize(false)}
appId={appId}
api_base_url={apiQuery.data?.base_url ?? ''}
mode={appInfo?.mode}
/>
{showAccess && (
<EnvironmentAccessControl
appId={appId}
@@ -286,7 +264,7 @@ export function EnvironmentWebAppCard({
})}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
@@ -296,7 +274,7 @@ export function EnvironmentWebAppCard({
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>

View File

@@ -1,14 +1,12 @@
'use client'
import type { AppSiteUpdatePayload } from '@dify/contracts/api/console/apps/types.gen'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { toast } from '@/app/notifications'
import { consoleClient, consoleQuery } from '@/service/console'
import { asyncRunSafe } from '@/utils'
export function useAccessPointActions(appId: string, canManageAccessPoint: boolean) {
const { t } = useTranslation(['common'])
@@ -23,31 +21,14 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
}
}, [appId, setAppDetail])
const handleResult = useCallback(
(error: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = error ? 'error' : 'success'
const resolvedMessage = message ?? (error ? 'modifiedUnsuccessfully' : 'modifiedSuccessfully')
if (!error) {
void refreshAppDetail()
}
toast(t(($) => $[`actionMsg.${resolvedMessage}`], { ns: 'common' }) as string, {
type,
})
},
[refreshAppDetail, t],
)
const saveSiteConfig = useCallback(
async (params: AppSiteUpdatePayload) => {
if (!canManageAccessPoint) return
const [error] = await asyncRunSafe(
consoleClient.apps.byAppId.site.post({
async (params: AppSiteUpdatePayload): Promise<boolean> => {
if (!canManageAccessPoint) return false
try {
await consoleClient.apps.byAppId.site.post({
params: { app_id: appId },
body: params,
}),
)
if (!error) {
})
void queryClient.invalidateQueries({
queryKey: consoleQuery.apps.byAppId.get.queryKey({
input: { params: { app_id: appId } },
@@ -56,14 +37,24 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() })
void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() })
void refreshAppDetail()
toast(
t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }),
{ type: 'success' },
)
return true
} catch {
toast(
t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }),
{ type: 'error' },
)
return false
}
handleResult(error)
},
[appId, canManageAccessPoint, handleResult, queryClient],
[appId, canManageAccessPoint, queryClient, refreshAppDetail, t],
)
return {
handleResult,
refreshAppDetail,
saveSiteConfig,
}

View File

@@ -2,6 +2,7 @@ import type { ReactElement } from 'react'
import type { Mock } from 'vite-plus/test'
import type { AnnotationItem } from '../type'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import * as React from 'react'
import { toast } from '@/app/notifications'
import {
@@ -141,17 +142,6 @@ vi.mock(
) : null,
}),
)
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
default: (props: any) =>
props.show ? (
<div data-testid="annotation-full-modal">
<button data-testid="hide-annotation-full-modal" onClick={props.onHide}>
hide-full
</button>
</div>
) : null,
}))
const mockNotify = vi.fn()
vi.spyOn(toast, 'success').mockImplementation((message, options) => {
mockNotify({ type: 'success', message, ...options })
@@ -195,7 +185,7 @@ const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationIt
const renderComponent = () => render(<Annotation appDetail={appDetail} />)
function render(ui: ReactElement) {
return renderWithConsoleQuery(ui, {
return renderWithConsoleQuery(<NuqsTestingAdapter>{ui}</NuqsTestingAdapter>, {
systemFeatures: { deployment_edition: 'CLOUD' },
features: { annotation_quota_limit: annotationQuota },
})
@@ -343,10 +333,20 @@ describe('Annotation', () => {
const toggle = await screen.findByRole('switch')
fireEvent.click(toggle)
expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
expect(
screen.getByRole('dialog', {
name: /annotatedResponse\.fullTipLine1.*annotatedResponse\.fullTipLine2/,
}),
).toBeInTheDocument()
fireEvent.click(screen.getByTestId('hide-annotation-full-modal'))
expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() =>
expect(
screen.queryByRole('dialog', {
name: /annotatedResponse\.fullTipLine1.*annotatedResponse\.fullTipLine2/,
}),
).not.toBeInTheDocument(),
)
})
it('should disable annotations and refetch config after the async job completes', async () => {

View File

@@ -1,10 +1,10 @@
import type { FC } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -69,14 +69,14 @@ const BatchAction: FC<IBatchActionProps> = ({
{t(($) => $['list.delete.title'], { ns: 'appAnnotation' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} onClick={handleBatchDelete}>
{t(($) => $['operation.delete'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>

View File

@@ -3,10 +3,10 @@
import type { FC } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import * as React from 'react'
@@ -33,14 +33,14 @@ const ClearAllAnnotationsConfirmModal: FC<Props> = ({ isShow, onHide, onConfirm
{title}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)

View File

@@ -2,10 +2,10 @@
import type { FC } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import {
@@ -142,7 +142,7 @@ const EditAnnotationModal: FC<Props> = ({
{t(($) => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
@@ -156,7 +156,7 @@ const EditAnnotationModal: FC<Props> = ({
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>

View File

@@ -18,7 +18,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { AnnotationFullModal } from '@/app/components/billing/annotation-full/modal'
import { toast } from '@/app/notifications'
import { APP_PAGE_LIMIT } from '@/config'
import { useDocLink } from '@/context/i18n'
@@ -335,12 +335,10 @@ const Annotation: FC<Props> = (props) => {
annotationConfig={annotationConfig!}
/>
)}
{isShowAnnotationFullModal && (
<AnnotationFullModal
show={isShowAnnotationFullModal}
onHide={() => setIsShowAnnotationFullModal(false)}
/>
)}
<AnnotationFullModal
open={isShowAnnotationFullModal}
onOpenChange={setIsShowAnnotationFullModal}
/>
</div>
</div>
)

View File

@@ -2,10 +2,10 @@
import type { FC } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import * as React from 'react'
@@ -32,14 +32,14 @@ const RemoveAnnotationConfirmModal: FC<Props> = ({ isShow, onHide, onRemove }) =
{title}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton tone="destructive" onClick={onRemove}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)

View File

@@ -3,10 +3,10 @@ import type { FC } from 'react'
import type { AnnotationItem, HitHistoryItem } from '../type'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
@@ -273,7 +273,7 @@ const ViewAnnotationModal: FC<Props> = ({ appId, isShow, onHide, item, onSave, o
{t(($) => $['feature.annotation.removeConfirm'], { ns: 'appDebug' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
@@ -287,7 +287,7 @@ const ViewAnnotationModal: FC<Props> = ({ appId, isShow, onHide, item, onSave, o
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>

View File

@@ -7,11 +7,11 @@ import type { ConfigurationPublishConfig } from '@/app/components/app/configurat
import type { Features, FileUpload } from '@/app/components/base/features/types'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { produce } from 'immer'
@@ -119,14 +119,14 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
{t(($) => $['resetConfig.message'], { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleConfirm}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>

View File

@@ -5,10 +5,15 @@ import type { InstallBundleCompleteCallback } from '@/app/components/plugins/ins
import type { Plugin } from '@/app/components/plugins/types'
import type ConfigContext from '@/context/debug-configuration'
import type { AgentTool } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { seedWorkspacePermissionsQuery } from '@/test/console/workspace-permissions'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum, ModelModeType } from '@/types/app'
import ConfigurationView from '../configuration-view'
@@ -30,10 +35,6 @@ vi.mock('@/app/components/app/configuration/debug', () => ({
default: () => <div data-testid="debug-panel" />,
}))
vi.mock('@/app/components/app/configuration/config/agent-setting-button', () => ({
default: () => <div data-testid="agent-setting-button" />,
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
@@ -41,14 +42,6 @@ vi.mock(
}),
)
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
default: () => <div data-testid="select-dataset" />,
}))
vi.mock('@/app/components/app/configuration/config-prompt/conversation-history/edit-modal', () => ({
default: () => <div data-testid="history-modal" />,
}))
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
default: () => <div data-testid="feature-panel" />,
}))
@@ -315,6 +308,27 @@ describe('ConfigurationView', () => {
pluginDependencyOnInstallComplete = undefined
})
it('routes history prefix saving and cancellation through the actual dialog', () => {
const onSaveHistory = vi.fn()
const onCloseHistoryModal = vi.fn()
render(
<ConfigurationView
{...createViewModel({ isShowHistoryModal: true, onSaveHistory, onCloseHistoryModal })}
/>,
)
const input = screen.getByRole('textbox', {
name: 'appDebug.feature.conversationHistory.editModal.userPrefix',
})
fireEvent.change(input, { target: { value: 'member' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSaveHistory).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ user_prefix: 'member' }),
)
expect(onCloseHistoryModal).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCloseHistoryModal).toHaveBeenCalledOnce()
})
it('should render a loading state before configuration data is ready', () => {
render(<ConfigurationView {...createViewModel({ showLoading: true })} />)
@@ -444,4 +458,49 @@ describe('ConfigurationView', () => {
expect(pluginDependencyOnInstallComplete).toBeUndefined()
})
it('closes the actual dataset selection dialog through its standard controlled callback', async () => {
const client = createTestQueryClient()
seedCurrentWorkspaceQuery(client)
seedWorkspacePermissionsQuery(client)
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (!url.includes('/datasets?')) throw new Error(`Unexpected request: ${url}`)
return Response.json({ data: [], page: 1, limit: 20, total: 0, has_more: false })
})
function SelectionView() {
const [open, setOpen] = React.useState(true)
return (
<ConfigurationView
{...createViewModel({
isShowSelectDataSet: open,
onCloseSelectDataSet: () => setOpen(false),
})}
/>
)
}
const result = render(
<QueryClientTestProvider queryClient={client}>
<SelectionView />
</QueryClientTestProvider>,
)
try {
const user = userEvent.setup()
expect(
screen.getByRole('dialog', { name: 'appDebug.feature.dataSet.selectTitle' }),
).toBeInTheDocument()
await user.keyboard('{Escape}')
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'appDebug.feature.dataSet.selectTitle' }),
).not.toBeInTheDocument(),
)
expect(
screen.getByRole('heading', { level: 1, name: 'appDebug.orchestrate' }),
).toBeInTheDocument()
} finally {
result.unmount()
client.clear()
fetchSpy.mockRestore()
}
})
})

View File

@@ -1,10 +1,22 @@
import { render, screen } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ConfigContext, { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import Prompt from '../simple-prompt-input'
const mockSetFeatures = vi.fn()
const mockEmit = vi.fn()
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({ features: { opening: { enabled: false } }, setFeatures: vi.fn() }),
getState: () => ({ features: { opening: { enabled: false } }, setFeatures: mockSetFeatures }),
}),
}))
@@ -29,3 +41,101 @@ describe('SimplePromptInput accessibility', () => {
expect(screen.queryByRole('heading')).not.toBeInTheDocument()
})
})
it('applies the real generator result to the prompt, variables, and opening statement before closing', async () => {
localStorage.clear()
sessionStorage.clear()
const user = userEvent.setup()
const setModelConfig = vi.fn()
const setPrevPromptConfig = vi.fn()
const setIntroduction = vi.fn()
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
client.setQueryData(
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryOptions({
input: { params: { model_type: ModelTypeEnum.textGeneration } },
}).queryKey,
{ data: [] },
)
client.setQueryData(commonQueryKeys.defaultModel(ModelTypeEnum.textGeneration), { data: null })
client.setQueryData(
consoleQuery.instructionGenerate.template.post.queryOptions({
input: { body: { type: 'prompt' } },
}).queryKey,
{ data: 'Describe a task' },
)
sessionStorage.setItem(
'gen-data-app-1-versions',
JSON.stringify([
{ modified: 'auto prompt', variables: ['city'], opening_statement: 'hello there' },
]),
)
function Owner() {
const defaults = useDebugConfigurationContext()
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription(mockEmit)
return (
<ConfigContext.Provider
value={{
...defaults,
appId: 'app-1',
setModelConfig,
setPrevPromptConfig,
setIntroduction,
}}
>
<Prompt mode={AppModeEnum.CHAT} promptTemplate="Hello" promptVariables={[]} noResize />
</ConfigContext.Provider>
)
}
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : String(input)
if (url.includes('/default-model?')) return Response.json({ data: null })
throw new Error(`Unexpected request: ${url}`)
})
const view = renderWithConsoleQuery(
<NuqsTestingAdapter>
<EventEmitterContextProvider>
<Owner />
</EventEmitterContextProvider>
</NuqsTestingAdapter>,
{ queryClient: client },
)
try {
await user.click(screen.getByRole('button', { name: 'appDebug.operation.automatic' }))
await user.click(await screen.findByRole('button', { name: 'appGeneration.generate.apply' }))
expect(setModelConfig).not.toHaveBeenCalled()
await user.click(
within(screen.getByRole('alertdialog')).getByRole('button', {
name: 'common.operation.confirm',
}),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
payload: 'auto prompt',
type: 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER',
}),
)
expect(setModelConfig).toHaveBeenCalledWith(
expect.objectContaining({
configs: expect.objectContaining({
prompt_template: 'auto prompt',
prompt_variables: [expect.objectContaining({ key: 'city', name: 'city' })],
}),
}),
)
expect(setPrevPromptConfig).toHaveBeenCalledTimes(1)
expect(setIntroduction).toHaveBeenCalledWith('hello there')
expect(mockSetFeatures).toHaveBeenCalledWith(
expect.objectContaining({
opening: expect.objectContaining({ enabled: true, opening_statement: 'hello there' }),
}),
)
} finally {
view.unmount()
client.clear()
fetchSpy.mockRestore()
}
})

View File

@@ -55,28 +55,6 @@ vi.mock('@/app/components/app/configuration/toast', () => ({
},
}))
vi.mock('@/app/components/app/configuration/config/automatic/automatic-btn', () => ({
default: ({ onClick }: { onClick: () => void }) => (
<button onClick={onClick}>automatic-btn</button>
),
}))
vi.mock('@/app/components/app/configuration/config/automatic/get-automatic-res', () => ({
default: ({ onFinished }: { onFinished: (value: Record<string, unknown>) => void }) => (
<button
onClick={() =>
onFinished({
modified: 'auto prompt',
variables: ['city'],
opening_statement: 'hello there',
})
}
>
finish-automatic
</button>
),
}))
vi.mock('@/app/components/base/prompt-editor', () => ({
default: (props: {
onBlur: () => void
@@ -212,40 +190,6 @@ describe('SimplePromptInput', () => {
)
})
it('should apply automatic generation results to prompt and opening statement', () => {
render(
<ConfigContext.Provider value={createContextValue()}>
<Prompt
mode={AppModeEnum.CHAT}
promptTemplate="Hello"
promptVariables={[]}
onChange={mockOnChange}
/>
</ConfigContext.Provider>,
)
fireEvent.click(screen.getByText('automatic-btn'))
fireEvent.click(screen.getByText('finish-automatic'))
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
payload: 'auto prompt',
type: 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER',
}),
)
expect(mockSetModelConfig).toHaveBeenCalledWith(
expect.objectContaining({
configs: expect.objectContaining({
prompt_template: 'auto prompt',
prompt_variables: [expect.objectContaining({ key: 'city', name: 'city' })],
}),
}),
)
expect(mockSetPrevPromptConfig).toHaveBeenCalled()
expect(mockSetIntroduction).toHaveBeenCalledWith('hello there')
expect(mockSetFeatures).toHaveBeenCalled()
})
it('should expose dataset and external tool metadata to the editor', () => {
render(
<ConfigContext.Provider

View File

@@ -1,7 +1,8 @@
import type { ConversationHistoriesRole } from '@/models/debug'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import EditModal from '../edit-modal'
import { EditModal } from '../edit-modal'
describe('Conversation history edit modal', () => {
const data: ConversationHistoriesRole = {
@@ -14,7 +15,7 @@ describe('Conversation history edit modal', () => {
})
it('should render provided prefixes', () => {
render(<EditModal isShow saveLoading={false} data={data} onClose={vi.fn()} onSave={vi.fn()} />)
render(<EditModal open data={data} onOpenChange={vi.fn()} onSave={vi.fn()} />)
expect(screen.getByDisplayValue('user')).toBeInTheDocument()
expect(screen.getByDisplayValue('assistant')).toBeInTheDocument()
@@ -22,7 +23,7 @@ describe('Conversation history edit modal', () => {
it('should update prefixes and save changes', () => {
const onSave = vi.fn()
render(<EditModal isShow saveLoading={false} data={data} onClose={vi.fn()} onSave={onSave} />)
render(<EditModal open data={data} onOpenChange={vi.fn()} onSave={onSave} />)
fireEvent.change(screen.getByDisplayValue('user'), { target: { value: 'member' } })
fireEvent.change(screen.getByDisplayValue('assistant'), { target: { value: 'helper' } })
@@ -36,10 +37,57 @@ describe('Conversation history edit modal', () => {
it('should call close handler', () => {
const onClose = vi.fn()
render(<EditModal isShow saveLoading={false} data={data} onClose={onClose} onSave={vi.fn()} />)
render(<EditModal open data={data} onOpenChange={onClose} onSave={vi.fn()} />)
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(onClose).toHaveBeenCalledTimes(1)
})
})
it('submits labeled prefixes with Enter and starts a fresh draft after cancellation', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
const data = { user_prefix: 'user', assistant_prefix: 'assistant' }
function Owner() {
const [open, setOpen] = React.useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Edit prefixes
</button>
<EditModal
open={open}
onOpenChange={setOpen}
data={data}
onSave={(value) => {
onSave(value)
setOpen(false)
}}
/>
</>
)
}
render(<Owner />)
await user.click(screen.getByRole('button', { name: 'Edit prefixes' }))
const prefix = screen.getByRole('textbox', {
name: 'appDebug.feature.conversationHistory.editModal.userPrefix',
})
await user.clear(prefix)
await user.type(prefix, 'discard')
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(onSave).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Edit prefixes' }))
const freshPrefix = screen.getByRole('textbox', {
name: 'appDebug.feature.conversationHistory.editModal.userPrefix',
})
expect(freshPrefix).toHaveValue('user')
await user.clear(freshPrefix)
await user.type(freshPrefix, 'member{Enter}')
expect(onSave).toHaveBeenCalledExactlyOnceWith({
user_prefix: 'member',
assistant_prefix: 'assistant',
})
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})

View File

@@ -1,80 +1,86 @@
'use client'
import type { FC } from 'react'
import type { ConversationHistoriesRole } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
type Props = Readonly<{
isShow: boolean
saveLoading: boolean
open: boolean
data: ConversationHistoriesRole
onClose: () => void
onSave: (data: any) => void
onOpenChange: (open: boolean) => void
onSave: (data: ConversationHistoriesRole) => void
}>
const EditModal: FC<Props> = ({ isShow, saveLoading, data, onClose, onSave }) => {
export const EditModal = React.memo(({ open, onOpenChange, data, onSave }: Props) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-120 overflow-hidden! border-none p-6 text-left align-middle">
<HistoryForm data={data} onSave={onSave} />
</DialogContent>
</Dialog>
))
function HistoryForm({ data, onSave }: Pick<Props, 'data' | 'onSave'>) {
const { t } = useTranslation(['appDebug', 'common'])
const [tempData, setTempData] = useState(data)
const [tempData, setTempData] = useState(() => data)
const id = React.useId()
return (
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open) onClose()
<form
onSubmit={(event) => {
event.preventDefault()
onSave(tempData)
}}
>
<DialogContent className="w-full max-w-120 overflow-hidden! border-none p-6 text-left align-middle">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['feature.conversationHistory.editModal.title'], { ns: 'appDebug' })}
</DialogTitle>
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['feature.conversationHistory.editModal.title'], { ns: 'appDebug' })}
</DialogTitle>
<div className="mt-6 text-sm leading-5.25 font-medium text-text-primary">
{t(($) => $['feature.conversationHistory.editModal.userPrefix'], { ns: 'appDebug' })}
</div>
<input
className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm/10"
value={tempData.user_prefix}
onChange={(e) =>
setTempData({
...tempData,
user_prefix: e.target.value,
})
}
/>
<label
htmlFor={`${id}-user`}
className="mt-6 block text-sm leading-5.25 font-medium text-text-primary"
>
{t(($) => $['feature.conversationHistory.editModal.userPrefix'], { ns: 'appDebug' })}
</label>
<input
id={`${id}-user`}
className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm/10"
value={tempData.user_prefix}
onChange={(e) =>
setTempData({
...tempData,
user_prefix: e.target.value,
})
}
/>
<div className="mt-6 text-sm leading-5.25 font-medium text-text-primary">
{t(($) => $['feature.conversationHistory.editModal.assistantPrefix'], { ns: 'appDebug' })}
</div>
<input
className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm/10"
value={tempData.assistant_prefix}
onChange={(e) =>
setTempData({
...tempData,
assistant_prefix: e.target.value,
})
}
placeholder={t(($) => $['chat.conversationNamePlaceholder'], { ns: 'common' }) || ''}
/>
<label
htmlFor={`${id}-assistant`}
className="mt-6 block text-sm leading-5.25 font-medium text-text-primary"
>
{t(($) => $['feature.conversationHistory.editModal.assistantPrefix'], { ns: 'appDebug' })}
</label>
<input
id={`${id}-assistant`}
className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm/10"
value={tempData.assistant_prefix}
onChange={(e) =>
setTempData({
...tempData,
assistant_prefix: e.target.value,
})
}
placeholder={t(($) => $['chat.conversationNamePlaceholder'], { ns: 'common' }) || ''}
/>
<div className="mt-10 flex justify-end">
<Button className="mr-2 shrink-0" onClick={onClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button
variant="primary"
className="shrink-0"
onClick={() => onSave(tempData)}
loading={saveLoading}
>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
<div className="mt-10 flex justify-end">
<DialogClose render={<Button className="mr-2 shrink-0" />}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</DialogClose>
<Button variant="primary" className="shrink-0" type="submit">
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</form>
)
}
export default React.memo(EditModal)

View File

@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/config-var'
import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn'
import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res'
import { GetAutomaticRes } from '@/app/components/app/configuration/config/automatic/get-automatic-res'
import { toast } from '@/app/components/app/configuration/toast'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import PromptEditor from '@/app/components/base/prompt-editor'
@@ -156,8 +156,10 @@ const Prompt: FC<ISimplePromptInput> = ({
}
}
const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] =
useBoolean(false)
const [
showAutomatic,
{ setTrue: showAutomaticTrue, setFalse: showAutomaticFalse, set: setShowAutomatic },
] = useBoolean(false)
const handleAutomaticRes = (res: GenRes) => {
// put eventEmitter in first place to prevent overwrite the configs.prompt_variables.But another problem is that prompt won't hight the prompt_variables.
eventEmitter?.emit({
@@ -311,17 +313,15 @@ const Prompt: FC<ISimplePromptInput> = ({
/>
)}
{showAutomatic && (
<GetAutomaticResModal
flowId={appId}
mode={mode as AppModeEnum}
isShow={showAutomatic}
onClose={showAutomaticFalse}
onFinished={handleAutomaticRes}
currentPrompt={promptTemplate}
isBasicMode
/>
)}
<GetAutomaticRes
flowId={appId}
mode={mode as AppModeEnum}
open={showAutomatic}
onOpenChange={setShowAutomatic}
onFinished={handleAutomaticRes}
currentPrompt={promptTemplate}
isBasicMode
/>
</div>
)
}

View File

@@ -8,7 +8,7 @@ import DebugConfigurationContext from '@/context/debug-configuration'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum } from '@/types/app'
import ConfigModal from '../index'
import { ConfigModal } from '../index'
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
let latestFormProps: Record<string, any> | null = null
@@ -117,7 +117,7 @@ const renderConfigModal = (payload: InputVar = createPayload()) =>
} as any
}
>
<ConfigModal isCreate isShow payload={payload} onClose={vi.fn()} onConfirm={vi.fn()} />
<ConfigModal isCreate open payload={payload} onOpenChange={vi.fn()} onConfirm={vi.fn()} />
</DebugConfigurationContext.Provider>,
)

View File

@@ -1,5 +1,5 @@
import type { InputVar } from '@/app/components/workflow/types'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { toast } from '@/app/components/app/configuration/toast'
@@ -9,7 +9,7 @@ import { commonQueryKeys } from '@/service/use-common'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { createAppDetailFixture } from '@/test/fixtures/app'
import { AppModeEnum, TransferMethod } from '@/types/app'
import ConfigModal from '../index'
import { ConfigModal } from '../index'
vi.mock('next/navigation', async () => ({
...(await vi.importActual<typeof import('next/navigation')>('next/navigation')),
@@ -60,13 +60,68 @@ describe('ConfigModal', () => {
})
})
it('focuses the scroll area and resets cancelled draft and validation on a fresh opening', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const payload = createPayload({ label: 'Question' })
function Owner() {
const [open, setOpen] = React.useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Edit variable
</button>
<ConfigModal open={open} onOpenChange={setOpen} payload={payload} onConfirm={onConfirm} />
</>
)
}
render(<Owner />)
await user.click(screen.getByRole('button', { name: 'Edit variable' }))
await waitFor(() => expect(screen.getByTestId('config-modal-scroll-area')).toHaveFocus())
expect(toastErrorSpy).not.toHaveBeenCalled()
await user.clear(screen.getByRole('textbox', { name: 'appDebug.variableConfig.labelName' }))
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(screen.getByRole('textbox', { name: 'appDebug.variableConfig.labelName' })).toBeInvalid()
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Edit variable' }))
expect(screen.getByRole('textbox', { name: 'appDebug.variableConfig.labelName' })).toHaveValue(
'Question',
)
expect(
screen.getByRole('textbox', { name: 'appDebug.variableConfig.labelName' }),
).not.toBeInvalid()
expect(onConfirm).not.toHaveBeenCalled()
})
it('keeps the original rename basis for the mounted editing session', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
const props = { open: true, onOpenChange: vi.fn(), onConfirm }
const view = render(<ConfigModal {...props} payload={createPayload({ label: 'Question' })} />)
const input = screen.getByRole('textbox', { name: 'appDebug.variableConfig.varName' })
await user.clear(input)
await user.type(input, 'renamed')
view.rerender(
<ConfigModal
{...props}
payload={createPayload({ variable: 'external', label: 'External' })}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onConfirm).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ variable: 'renamed' }),
expect.objectContaining({ payload: { beforeKey: 'question', afterKey: 'renamed' } }),
)
})
it('should copy the variable name into the label when the label is empty', () => {
render(
<ConfigModal
isCreate
isShow
open
payload={createPayload()}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
)
@@ -82,9 +137,9 @@ describe('ConfigModal', () => {
render(
<ConfigModal
isCreate
isShow
open
payload={createPayload({ label: 'Question' })}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -108,9 +163,9 @@ describe('ConfigModal', () => {
render(
<ConfigModal
isCreate
isShow
open
payload={createPayload({ label: 'Question' })}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -138,9 +193,9 @@ describe('ConfigModal', () => {
const user = userEvent.setup()
render(
<ConfigModal
isShow
open
payload={createPayload({ variable: '', label: '' })}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
)
@@ -162,13 +217,13 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
payload={createPayload({
type: InputVarType.number,
label: 'Amount',
default: initialDefault,
})}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -199,9 +254,9 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
payload={createPayload({ type: InputVarType.number, label: 'Amount', default: -7.123456 })}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -229,9 +284,9 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
payload={createPayload({ label: 'Question', default: initialDefault })}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -259,13 +314,13 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
payload={createPayload({
type,
label: 'Question',
default: type === InputVarType.number ? 9 : 'hello',
})}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -291,7 +346,7 @@ describe('ConfigModal', () => {
const onClose = vi.fn()
render(
<ConfigModal
isShow
open
supportFile
payload={createPayload({
type,
@@ -302,7 +357,7 @@ describe('ConfigModal', () => {
allowed_file_extensions: ['.txt'],
allowed_file_upload_methods: [TransferMethod.local_file],
})}
onClose={onClose}
onOpenChange={onClose}
onConfirm={onConfirm}
/>,
)
@@ -337,7 +392,7 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
supportFile
payload={createPayload({
type: InputVarType.multiFiles,
@@ -347,7 +402,7 @@ describe('ConfigModal', () => {
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
})}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -373,7 +428,7 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
const { queryClient } = render(
<ConfigModal
isShow
open
supportFile
payload={createPayload({
type: InputVarType.multiFiles,
@@ -383,7 +438,7 @@ describe('ConfigModal', () => {
allowed_file_types: [SupportUploadFileTypes.document],
allowed_file_upload_methods: [TransferMethod.local_file],
})}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -415,9 +470,9 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
payload={createPayload({ label: field === 'variable' ? '' : 'Question', [field]: '' })}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -448,14 +503,14 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
payload={createPayload({
type: InputVarType.select,
label: 'Question',
options,
default: undefined,
})}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -485,7 +540,7 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
supportFile
payload={createPayload({
type: InputVarType.singleFile,
@@ -495,7 +550,7 @@ describe('ConfigModal', () => {
allowed_file_extensions: [],
allowed_file_upload_methods: [TransferMethod.local_file],
})}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -520,13 +575,13 @@ describe('ConfigModal', () => {
const onConfirm = vi.fn()
render(
<ConfigModal
isShow
open
payload={createPayload({
type: InputVarType.jsonObject,
label: 'Question',
json_schema: '{"type":"object"}',
})}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
/>,
)
@@ -559,14 +614,14 @@ describe('ConfigModal', () => {
(type) => {
render(
<ConfigModal
isShow
open
payload={createPayload({
type,
label: 'Question',
options: ['alpha'],
default: undefined,
})}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
)
@@ -586,9 +641,9 @@ describe('ConfigModal', () => {
const onClose = vi.fn()
render(
<ConfigModal
isShow
open
payload={createPayload({ label: 'Question' })}
onClose={onClose}
onOpenChange={onClose}
onConfirm={onConfirm}
/>,
)
@@ -603,9 +658,9 @@ describe('ConfigModal', () => {
render(
<ConfigModal
isCreate
isShow
open
payload={createPayload({ label: 'Question' })}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
)
@@ -622,9 +677,9 @@ describe('ConfigModal', () => {
render(
<ConfigModal
isCreate
isShow
open
payload={createPayload({ label: '' })}
onClose={vi.fn()}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
/>,
)

View File

@@ -1,10 +1,12 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import type { DialogContentProps } from '@langgenius/dify-ui/dialog'
import type { ChangeEvent, RefObject } from 'react'
import type { Item as SelectItem } from './type-select'
import type { ConfigModalValidationError } from './utils'
import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useQueryClient } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -20,7 +22,6 @@ import {
getNewVarInWorkflow,
replaceSpaceWithUnderscoreInVarNameInput,
} from '@/utils/var'
import ModalFoot from '../modal-foot'
import ConfigModalFormFields from './form-fields'
import {
buildSelectOptions,
@@ -33,35 +34,54 @@ import {
validateConfigModalPayload,
} from './utils'
type IConfigModalProps = {
type ConfigModalProps = {
isCreate?: boolean
payload?: InputVar
isShow: boolean
varKeys?: string[]
onClose: () => void
open: boolean
finalFocus?: DialogContentProps['finalFocus']
onOpenChange: (open: boolean) => void
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
supportFile?: boolean
showHiddenField?: boolean
}
const ConfigModal: FC<IConfigModalProps> = ({
export const ConfigModal = React.memo(
({ open, onOpenChange, finalFocus, ...formProps }: ConfigModalProps) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
initialFocus={scrollAreaRef}
finalFocus={finalFocus}
className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden! border-none p-0! text-left align-middle"
>
<ConfigModalForm {...formProps} scrollAreaRef={scrollAreaRef} />
</DialogContent>
</Dialog>
)
},
)
function ConfigModalForm({
isCreate,
payload,
isShow,
onClose,
onConfirm,
showHiddenField,
supportFile,
}) => {
scrollAreaRef,
}: Omit<ConfigModalProps, 'open' | 'onOpenChange' | 'finalFocus'> & {
scrollAreaRef: RefObject<HTMLDivElement | null>
}) {
const [initialPayload] = useState(() => payload)
const { modelConfig } = useContext(ConfigContext)
const { t } = useTranslation(['appDebug', 'workflow'])
const { t: tCommon } = useTranslation(['common'])
const [tempPayload, setTempPayload] = useState<InputVar>(() =>
normalizeSelectDefaultValue(payload || (getNewVarInWorkflow('') as any)),
normalizeSelectDefaultValue(initialPayload || getNewVarInWorkflow('')),
)
const [validationError, setValidationError] = useState<ConfigModalValidationError>()
const queryClient = useQueryClient()
const { type, options, max_length } = tempPayload
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore((state) => state.appDetail)
const isBasicApp =
appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
@@ -69,14 +89,10 @@ const ConfigModal: FC<IConfigModalProps> = ({
() => getJsonSchemaEditorValue(type, tempPayload.json_schema),
[tempPayload.json_schema, type],
)
useEffect(() => {
// To fix the first input element auto focus, then directly close modal will raise error
if (isShow) modalRef.current?.focus()
}, [isShow])
useEffect(() => {
if (validationError)
modalRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}, [validationError])
scrollAreaRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}, [scrollAreaRef, validationError])
const isStringInput = isStringInputType(type)
const checkVariableName = useCallback(
@@ -177,7 +193,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
)
const { errorMessage, errorField, moreInfo, payloadToSave } = validateConfigModalPayload({
tempPayload,
payload,
payload: initialPayload,
maxFileUploadLimit: Number(fileUploadConfig?.workflow_file_upload_limit) || undefined,
t,
})
@@ -195,69 +211,64 @@ const ConfigModal: FC<IConfigModalProps> = ({
}
return (
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open) onClose()
<form
noValidate
className="flex min-h-0 flex-col"
onSubmit={(event) => {
event.preventDefault()
handleConfirm()
}}
>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden! border-none p-0! text-left align-middle">
<form
noValidate
className="flex min-h-0 flex-col"
onSubmit={(event) => {
event.preventDefault()
handleConfirm()
}}
>
<DialogTitle className="shrink-0 px-6 pt-6 title-2xl-semi-bold text-text-primary">
{t(($) => $[`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`], {
ns: 'appDebug',
})}
</DialogTitle>
<DialogTitle className="shrink-0 px-6 pt-6 title-2xl-semi-bold text-text-primary">
{t(($) => $[`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`], {
ns: 'appDebug',
})}
</DialogTitle>
<div
ref={modalRef}
tabIndex={-1}
data-testid="config-modal-scroll-area"
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-6 py-4 pb-8"
>
<ConfigModalFormFields
checkboxDefaultSelectValue={checkboxDefaultSelectValue}
isStringInput={isStringInput}
jsonSchemaStr={jsonSchemaStr}
maxLength={max_length}
modelId={modelConfig.model_id}
onFilePayloadChange={(nextPayload) => {
if (
validationError &&
(validationError.field === 'allowed_file_types' ||
validationError.field === 'allowed_file_extensions') &&
(nextPayload[validationError.field] !== tempPayload[validationError.field] ||
nextPayload.allowed_file_types !== tempPayload.allowed_file_types)
)
setValidationError(undefined)
setTempPayload(nextPayload as InputVar)
}}
onJSONSchemaChange={handleJSONSchemaChange}
onPayloadChange={handlePayloadChange}
onTypeChange={handleTypeChange}
onVarKeyBlur={handleVarKeyBlur}
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
showHiddenField={showHiddenField}
tempPayload={tempPayload}
validationError={validationError}
t={t}
/>
</div>
<div className="shrink-0 px-6 pt-2 pb-6">
<ModalFoot confirmType="submit" onCancel={onClose} />
</div>
</form>
</DialogContent>
</Dialog>
<div
ref={scrollAreaRef}
tabIndex={-1}
data-testid="config-modal-scroll-area"
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-6 py-4 pb-8"
>
<ConfigModalFormFields
checkboxDefaultSelectValue={checkboxDefaultSelectValue}
isStringInput={isStringInput}
jsonSchemaStr={jsonSchemaStr}
maxLength={max_length}
modelId={modelConfig.model_id}
onFilePayloadChange={(nextPayload) => {
if (
validationError &&
(validationError.field === 'allowed_file_types' ||
validationError.field === 'allowed_file_extensions') &&
(nextPayload[validationError.field] !== tempPayload[validationError.field] ||
nextPayload.allowed_file_types !== tempPayload.allowed_file_types)
)
setValidationError(undefined)
setTempPayload(nextPayload as InputVar)
}}
onJSONSchemaChange={handleJSONSchemaChange}
onPayloadChange={handlePayloadChange}
onTypeChange={handleTypeChange}
onVarKeyBlur={handleVarKeyBlur}
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
showHiddenField={showHiddenField}
tempPayload={tempPayload}
validationError={validationError}
t={t}
/>
</div>
<div className="shrink-0 px-6 pt-2 pb-6">
<div className="flex justify-end gap-2">
<DialogClose render={<Button />}>{tCommon(($) => $['operation.cancel'])}</DialogClose>
<Button type="submit" variant="primary">
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</div>
</form>
)
}
export default React.memo(ConfigModal)

View File

@@ -5,11 +5,11 @@ import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
@@ -30,7 +30,7 @@ import { useModalContext } from '@/context/modal-context'
import { AppModeEnum } from '@/types/app'
import { getNewVar, hasDuplicateStr } from '@/utils/var'
import Panel from '../base/feature-panel'
import EditModal from './config-modal'
import { ConfigModal } from './config-modal'
import SelectVarType from './select-var-type'
import VarItem from './var-item'
@@ -101,6 +101,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
const { eventEmitter } = useEventEmitterContextContext()
const hasVar = promptVariables.length > 0
const editButtonRef = React.useRef<HTMLButtonElement>(null)
const [currIndex, setCurrIndex] = useState<number>(-1)
const currItem = currIndex !== -1 ? promptVariables[currIndex] : null
const currItemToEdit = useMemo(() => {
@@ -254,7 +255,10 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
[dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal],
)
const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false)
const [
isShowEditModal,
{ setTrue: showEditModal, setFalse: hideEditModal, set: setEditModalOpen },
] = useBoolean(false)
const handleConfig = useCallback(
({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => {
@@ -361,6 +365,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
label={name}
required={!!required}
type={type}
editButtonRef={currIndex === getItemKey(index) ? editButtonRef : undefined}
onEdit={() =>
handleConfig({
type,
@@ -381,19 +386,17 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
</div>
)}
{isShowEditModal && (
<EditModal
payload={currItemToEdit!}
isShow={isShowEditModal}
onClose={hideEditModal}
onConfirm={(item) => {
const isValid = updatePromptVariableItem(item)
if (!isValid) return
hideEditModal()
}}
varKeys={promptVariables.map((v) => v.key)}
/>
)}
<ConfigModal
payload={currItemToEdit ?? undefined}
open={isShowEditModal}
onOpenChange={setEditModalOpen}
finalFocus={editButtonRef}
onConfirm={(item) => {
const isValid = updatePromptVariableItem(item)
if (!isValid) return
hideEditModal()
}}
/>
<AlertDialog
open={isShowDeleteContextVarModal}
@@ -411,7 +414,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
{t(($) => $['feature.dataSet.queryVariable.deleteContextVarTip'], { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
@@ -423,7 +426,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Panel>

View File

@@ -1,26 +0,0 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type IModalFootProps = {
onConfirm?: () => void
confirmType?: 'button' | 'submit'
onCancel: () => void
}
const ModalFoot: FC<IModalFootProps> = ({ onConfirm, onCancel, confirmType = 'button' }) => {
const { t } = useTranslation(['common'])
return (
<div className="flex justify-end gap-2">
<Button type="button" onClick={onCancel}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button type={confirmType} variant="primary" onClick={onConfirm}>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
)
}
export default React.memo(ModalFoot)

View File

@@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import type { FC, Ref } from 'react'
import type { IInputTypeIconProps } from './input-type-icon'
import type { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
import { cn } from '@langgenius/dify-ui/cn'
@@ -19,6 +19,7 @@ type ItemProps = {
required: boolean
type: string
onEdit: () => void
editButtonRef?: Ref<HTMLButtonElement>
onRemove: () => void
canDrag?: boolean
dragHandleProps?: ReturnType<ReturnType<typeof useKeyboardSortable>['getHandleProps']>
@@ -32,6 +33,7 @@ const VarItem: FC<ItemProps> = ({
required,
type,
onEdit,
editButtonRef,
onRemove,
canDrag,
dragHandleProps,
@@ -86,6 +88,7 @@ const VarItem: FC<ItemProps> = ({
type="button"
aria-label={t(($) => $['operation.edit'], { ns: 'common' })}
className="mr-1 flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
ref={editButtonRef}
onClick={onEdit}
>
<RiEditLine className="size-4 text-text-tertiary" aria-hidden="true" />

View File

@@ -1,91 +0,0 @@
import type { AgentConfig } from '@/models/debug'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AgentStrategy } from '@/types/app'
import AgentSettingButton from '../agent-setting-button'
let latestAgentSettingProps: any
vi.mock('../agent/agent-setting', () => ({
AgentSetting: (props: any) => {
latestAgentSettingProps = props
return (
<div data-testid="agent-setting">
<button onClick={() => props.onSave({ ...props.payload, max_iteration: 9 })}>
save-agent
</button>
<button onClick={props.onCancel}>cancel-agent</button>
</div>
)
},
}))
const createAgentConfig = (overrides: Partial<AgentConfig> = {}): AgentConfig => ({
enabled: true,
strategy: AgentStrategy.react,
max_iteration: 3,
tools: [],
...overrides,
})
const setup = (overrides: Partial<React.ComponentProps<typeof AgentSettingButton>> = {}) => {
const props: React.ComponentProps<typeof AgentSettingButton> = {
isFunctionCall: false,
isChatModel: true,
onAgentSettingChange: vi.fn(),
agentConfig: createAgentConfig(),
...overrides,
}
const user = userEvent.setup()
render(<AgentSettingButton {...props} />)
return { props, user }
}
beforeEach(() => {
vi.clearAllMocks()
latestAgentSettingProps = undefined
})
describe('AgentSettingButton', () => {
it('should render button label from translation key', () => {
setup()
expect(screen.getByRole('button', { name: 'appDebug.agent.setting.name' })).toBeInTheDocument()
})
it('should open AgentSetting with the provided configuration when clicked', async () => {
const { user, props } = setup({ isFunctionCall: true, isChatModel: false })
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
expect(screen.getByTestId('agent-setting')).toBeInTheDocument()
expect(latestAgentSettingProps.isFunctionCall).toBe(true)
expect(latestAgentSettingProps.isChatModel).toBe(false)
expect(latestAgentSettingProps.payload).toEqual(props.agentConfig)
})
it('should call onAgentSettingChange and close when AgentSetting saves', async () => {
const { user, props } = setup()
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
await user.click(screen.getByText('save-agent'))
expect(props.onAgentSettingChange).toHaveBeenCalledTimes(1)
expect(props.onAgentSettingChange).toHaveBeenCalledWith({
...props.agentConfig,
max_iteration: 9,
})
expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument()
})
it('should close AgentSetting without saving when cancel is triggered', async () => {
const { user, props } = setup()
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
await user.click(screen.getByText('cancel-agent'))
expect(props.onAgentSettingChange).not.toHaveBeenCalled()
expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument()
})
})

View File

@@ -1,53 +0,0 @@
'use client'
import type { FC } from 'react'
import type { AgentConfig } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AgentSetting } from './agent/agent-setting'
type Props = Readonly<{
isFunctionCall: boolean
isChatModel: boolean
agentConfig?: AgentConfig
disabled?: boolean
onAgentSettingChange: (payload: AgentConfig) => void
}>
const AgentSettingButton: FC<Props> = ({
onAgentSettingChange,
isFunctionCall,
isChatModel,
agentConfig,
disabled = false,
}) => {
const { t } = useTranslation(['appDebug'])
const [isShowAgentSetting, setIsShowAgentSetting] = useState(false)
return (
<>
<Button
onClick={() => setIsShowAgentSetting(true)}
className="mr-2 shrink-0"
disabled={disabled}
>
<span className="i-ri-settings-2-line size-4 text-text-tertiary" />
{t(($) => $['agent.setting.name'], { ns: 'appDebug' })}
</Button>
{isShowAgentSetting && (
<AgentSetting
isFunctionCall={isFunctionCall}
payload={agentConfig as AgentConfig}
isChatModel={isChatModel}
onSave={(payloadNew) => {
onAgentSettingChange(payloadNew)
setIsShowAgentSetting(false)
}}
onCancel={() => setIsShowAgentSetting(false)}
/>
)}
</>
)
}
export default React.memo(AgentSettingButton)

View File

@@ -1,127 +1,96 @@
import type { AgentConfig } from '@/models/debug'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { MAX_ITERATIONS_NUM } from '@/config'
import { AgentSetting } from '../index'
import { AgentStrategy } from '@/types/app'
import { AgentSettingDialog } from '../index'
const basePayload = {
const basePayload: AgentConfig = {
enabled: true,
strategy: 'react',
strategy: AgentStrategy.react,
max_iteration: 5,
tools: [],
}
const renderModal = (props?: Partial<React.ComponentProps<typeof AgentSetting>>) => {
const onCancel = vi.fn()
const onSave = vi.fn()
const utils = render(
<AgentSetting
function SettingsFixture({ onSave }: { onSave: (payload: AgentConfig) => void }) {
const [payload, setPayload] = useState(basePayload)
return (
<AgentSettingDialog
isChatModel
payload={basePayload as AgentConfig}
payload={payload}
isFunctionCall={false}
onCancel={onCancel}
onSave={onSave}
{...props}
/>,
onSave={(value) => {
setPayload(value)
onSave(value)
}}
/>
)
return { ...utils, onCancel, onSave }
}
describe('AgentSetting', () => {
it('should render agent mode description and default prompt section when not function call', () => {
renderModal()
const settingName = 'appDebug.agent.setting.name'
expect(screen.getByText('appDebug.agent.agentMode')).toBeInTheDocument()
describe('AgentSettingDialog', () => {
it('discards cancelled edits, submits current iterations, and reopens the saved configuration', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(<SettingsFixture onSave={onSave} />)
const trigger = screen.getByRole('button', { name: settingName })
await user.click(trigger)
expect(screen.getByText('appDebug.agent.agentModeType.ReACT')).toBeInTheDocument()
expect(screen.getByText('tools.builtInPromptTitle')).toBeInTheDocument()
})
it('should display function call mode when isFunctionCall true', () => {
renderModal({ isFunctionCall: true })
expect(screen.getByText('appDebug.agent.agentModeType.functionCall')).toBeInTheDocument()
expect(screen.queryByText('tools.builtInPromptTitle')).not.toBeInTheDocument()
})
it('should update iteration via slider and number input', () => {
renderModal()
const slider = screen.getByRole('slider')
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(slider, { target: { value: '7' } })
expect(screen.getAllByDisplayValue('7')).toHaveLength(2)
fireEvent.change(numberInput, { target: { value: '2' } })
expect(screen.getAllByDisplayValue('2')).toHaveLength(2)
})
it('should clamp iteration value within min/max range', () => {
renderModal()
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(numberInput, { target: { value: '0' } })
expect(screen.getAllByDisplayValue('1')).toHaveLength(2)
fireEvent.change(numberInput, { target: { value: '999' } })
expect(screen.getAllByDisplayValue(String(MAX_ITERATIONS_NUM))).toHaveLength(2)
})
it('should call onCancel when cancel button clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalled()
})
it('should call onCancel when Escape is pressed', () => {
const { onCancel } = renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when the backdrop is clicked', () => {
const { onCancel } = renderModal()
const dialog = screen.getByRole('dialog')
fireEvent.pointerDown(document.body, { target: document.body })
fireEvent.mouseDown(document.body, { target: document.body })
fireEvent.click(document.body, { target: document.body })
expect(dialog).toBeInTheDocument()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onSave with updated payload', async () => {
const { onSave } = renderModal()
const numberInput = screen.getByRole('spinbutton')
fireEvent.change(numberInput, { target: { value: '6' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
})
await user.clear(screen.getByRole('spinbutton'))
await user.type(screen.getByRole('spinbutton'), '7')
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(onSave).not.toHaveBeenCalled()
expect(trigger).toHaveFocus()
await user.keyboard('{Enter}')
expect(screen.getByRole('spinbutton')).toHaveValue(5)
await user.clear(screen.getByRole('spinbutton'))
await user.type(screen.getByRole('spinbutton'), '6{Enter}')
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 6 }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(trigger)
expect(screen.getByRole('spinbutton')).toHaveValue(6)
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(trigger).toHaveFocus()
})
it('should save the minimum iteration when the number input is empty', async () => {
const { onSave } = renderModal()
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } })
expect(screen.getByRole('slider')).toHaveValue('1')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
})
it('clamps iterations and saves the minimum when the number input is cleared', async () => {
const user = userEvent.setup()
const onSave = vi.fn()
render(<AgentSettingDialog isChatModel payload={basePayload} isFunctionCall onSave={onSave} />)
await user.click(screen.getByRole('button', { name: settingName }))
expect(screen.queryByText('tools.builtInPromptTitle')).not.toBeInTheDocument()
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '999')
expect(input).toHaveValue(MAX_ITERATIONS_NUM)
await user.clear(input)
await user.type(input, '0')
expect(input).toHaveValue(1)
await user.clear(input)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ max_iteration: 1 }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('keeps readonly settings unavailable', async () => {
const user = userEvent.setup()
render(
<AgentSettingDialog
isChatModel
payload={basePayload}
isFunctionCall
disabled
onSave={vi.fn()}
/>,
)
const trigger = screen.getByRole('button', { name: settingName })
expect(trigger).toBeDisabled()
await user.click(trigger)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})

View File

@@ -1,7 +1,14 @@
'use client'
import type { DialogActions } from '@langgenius/dify-ui/dialog'
import type { AgentConfig } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@langgenius/dify-ui/dialog'
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
@@ -12,7 +19,7 @@ import {
SliderThumb,
SliderTrack,
} from '@langgenius/dify-ui/slider'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_AGENT_PROMPT, MAX_ITERATIONS_NUM } from '@/config'
import ItemPanel from './item-panel'
@@ -21,13 +28,41 @@ type Props = Readonly<{
isChatModel: boolean
payload: AgentConfig
isFunctionCall: boolean
onCancel: () => void
disabled?: boolean
onSave: (payload: AgentConfig) => void
}>
const maxIterationsMin = 1
export function AgentSetting({ isChatModel, payload, isFunctionCall, onCancel, onSave }: Props) {
export function AgentSettingDialog({ disabled, onSave, ...props }: Props) {
const { t } = useTranslation(['appDebug'])
const actionsRef = useRef<DialogActions>(null)
return (
<Dialog actionsRef={actionsRef}>
<DialogTrigger render={<Button className="mr-2 shrink-0" disabled={disabled} />}>
<span aria-hidden className="i-ri-settings-2-line size-4 text-text-tertiary" />
{t(($) => $['agent.setting.name'])}
</DialogTrigger>
<DialogContent className="top-2 right-2 bottom-2 left-auto flex h-auto max-h-none w-160 max-w-[calc(100vw-1rem)] translate-x-0 translate-y-0 flex-col overflow-hidden rounded-xl p-0">
<AgentSettingsForm
{...props}
onSave={(payload) => {
onSave(payload)
actionsRef.current?.close()
}}
/>
</DialogContent>
</Dialog>
)
}
function AgentSettingsForm({
isChatModel,
payload,
isFunctionCall,
onSave,
}: Omit<Props, 'disabled'>) {
const { t } = useTranslation(['appDebug', 'common', 'tools'])
const [tempPayload, setTempPayload] = useState(payload)
const maximumIterationsLabel = t(($) => $['agent.setting.maximumIterations.name'], {
@@ -45,141 +80,137 @@ export function AgentSetting({ isChatModel, payload, isFunctionCall, onCancel, o
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onCancel()
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
event.preventDefault()
handleSave()
}}
>
<DialogContent className="top-2 right-2 bottom-2 left-auto flex h-auto max-h-none w-160 max-w-[calc(100vw-1rem)] translate-x-0 translate-y-0 flex-col overflow-hidden rounded-xl p-0">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-divider-regular pr-5 pl-6">
<DialogTitle className="text-base leading-6 font-semibold text-text-primary">
{t(($) => $['agent.setting.name'], { ns: 'appDebug' })}
</DialogTitle>
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="sm"
className="static z-auto size-6 shrink-0 rounded-2xl"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
</div>
{/* Body */}
<div
className="grow overflow-y-auto border-b border-divider-regular p-6 pt-5 pb-17"
style={{
borderBottom: 'rgba(0, 0, 0, 0.05)',
}}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-divider-regular pr-5 pl-6">
<DialogTitle className="text-base leading-6 font-semibold text-text-primary">
{t(($) => $['agent.setting.name'], { ns: 'appDebug' })}
</DialogTitle>
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="sm"
className="static z-auto size-6 shrink-0 rounded-2xl"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
</div>
{/* Body */}
<div
className="grow overflow-y-auto border-b border-divider-regular p-6 pt-5 pb-17"
style={{
borderBottom: 'rgba(0, 0, 0, 0.05)',
}}
>
{/* Agent Mode */}
<ItemPanel
className="mb-4"
icon={
<span
aria-hidden
className="i-custom-vender-solid-communication-cute-robot size-4 text-indigo-600"
/>
}
name={t(($) => $['agent.agentMode'], { ns: 'appDebug' })}
description={t(($) => $['agent.agentModeDes'], { ns: 'appDebug' })}
>
{/* Agent Mode */}
<ItemPanel
className="mb-4"
icon={
<span
aria-hidden
className="i-custom-vender-solid-communication-cute-robot size-4 text-indigo-600"
/>
}
name={t(($) => $['agent.agentMode'], { ns: 'appDebug' })}
description={t(($) => $['agent.agentModeDes'], { ns: 'appDebug' })}
>
<div className="text-[13px] leading-4.5 font-medium text-text-primary">
{isFunctionCall
? t(($) => $['agent.agentModeType.functionCall'], { ns: 'appDebug' })
: t(($) => $['agent.agentModeType.ReACT'], { ns: 'appDebug' })}
<div className="text-[13px] leading-4.5 font-medium text-text-primary">
{isFunctionCall
? t(($) => $['agent.agentModeType.functionCall'], { ns: 'appDebug' })
: t(($) => $['agent.agentModeType.ReACT'], { ns: 'appDebug' })}
</div>
</ItemPanel>
<ItemPanel
className="mb-4"
icon={
<span
aria-hidden
className="i-custom-vender-solid-education-unblur h-4 w-4 text-[#FB6514]"
/>
}
name={maximumIterationsLabel}
description={t(($) => $['agent.setting.maximumIterations.description'], {
ns: 'appDebug',
})}
>
<Fieldset className="flex items-center">
<FieldsetLegend className="sr-only">{maximumIterationsLabel}</FieldsetLegend>
<Slider
className="mr-3 w-39"
min={maxIterationsMin}
max={MAX_ITERATIONS_NUM}
value={sliderValue}
onValueChange={(value) => {
setTempPayload({
...tempPayload,
max_iteration: value,
})
}}
>
<SliderLabel className="sr-only">{maximumIterationsLabel}</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
<SliderThumb />
</SliderTrack>
</SliderControl>
</Slider>
<input
aria-label={maximumIterationsLabel}
type="number"
min={maxIterationsMin}
max={MAX_ITERATIONS_NUM}
step={1}
className="block h-7 w-11 rounded-lg border-0 bg-components-input-bg-normal px-1.5 pl-1 leading-7 text-text-primary placeholder:text-text-tertiary focus:inset-ring-1 focus:inset-ring-primary-600"
value={Number.isFinite(tempPayload.max_iteration) ? tempPayload.max_iteration : ''}
onChange={(e) => {
let value = Number.parseInt(e.target.value, 10)
if (value < maxIterationsMin) value = maxIterationsMin
if (value > MAX_ITERATIONS_NUM) value = MAX_ITERATIONS_NUM
setTempPayload({
...tempPayload,
max_iteration: value,
})
}}
/>
</Fieldset>
</ItemPanel>
{!isFunctionCall && (
<div className="rounded-xl bg-background-section-burn py-2 shadow-xs">
<div className="flex h-8 items-center px-4 text-sm/6 font-semibold text-text-secondary">
{t(($) => $.builtInPromptTitle, { ns: 'tools' })}
</div>
</ItemPanel>
<ItemPanel
className="mb-4"
icon={
<span
aria-hidden
className="i-custom-vender-solid-education-unblur h-4 w-4 text-[#FB6514]"
/>
}
name={maximumIterationsLabel}
description={t(($) => $['agent.setting.maximumIterations.description'], {
ns: 'appDebug',
})}
>
<Fieldset className="flex items-center">
<FieldsetLegend className="sr-only">{maximumIterationsLabel}</FieldsetLegend>
<Slider
className="mr-3 w-39"
min={maxIterationsMin}
max={MAX_ITERATIONS_NUM}
value={sliderValue}
onValueChange={(value) => {
setTempPayload({
...tempPayload,
max_iteration: value,
})
}}
>
<SliderLabel className="sr-only">{maximumIterationsLabel}</SliderLabel>
<SliderControl>
<SliderTrack>
<SliderIndicator />
<SliderThumb />
</SliderTrack>
</SliderControl>
</Slider>
<input
aria-label={maximumIterationsLabel}
type="number"
min={maxIterationsMin}
max={MAX_ITERATIONS_NUM}
step={1}
className="block h-7 w-11 rounded-lg border-0 bg-components-input-bg-normal px-1.5 pl-1 leading-7 text-text-primary placeholder:text-text-tertiary focus:inset-ring-1 focus:inset-ring-primary-600"
value={tempPayload.max_iteration}
onChange={(e) => {
let value = Number.parseInt(e.target.value, 10)
if (value < maxIterationsMin) value = maxIterationsMin
if (value > MAX_ITERATIONS_NUM) value = MAX_ITERATIONS_NUM
setTempPayload({
...tempPayload,
max_iteration: value,
})
}}
/>
</Fieldset>
</ItemPanel>
{!isFunctionCall && (
<div className="rounded-xl bg-background-section-burn py-2 shadow-xs">
<div className="flex h-8 items-center px-4 text-sm/6 font-semibold text-text-secondary">
{t(($) => $.builtInPromptTitle, { ns: 'tools' })}
</div>
<div className="h-99 overflow-y-auto px-4 text-sm leading-5 font-normal whitespace-pre-line text-text-secondary">
{isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion}
</div>
<div className="px-4">
<div className="inline-flex h-5 items-center rounded-md bg-components-input-bg-normal px-1 text-xs leading-4.5 font-medium text-text-tertiary">
{
(isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion)
.length
}
</div>
<div className="h-99 overflow-y-auto px-4 text-sm leading-5 font-normal whitespace-pre-line text-text-secondary">
{isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion}
</div>
<div className="px-4">
<div className="inline-flex h-5 items-center rounded-md bg-components-input-bg-normal px-1 text-xs leading-4.5 font-medium text-text-tertiary">
{(isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion).length}
</div>
</div>
)}
</div>
<div className="sticky bottom-0 z-5 flex w-full justify-end border-t border-divider-regular bg-background-section-burn px-6 py-4">
<Button type="button" onClick={onCancel} className="mr-2">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button type="button" variant="primary" onClick={handleSave}>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)}
</div>
<div className="sticky bottom-0 z-5 flex w-full justify-end border-t border-divider-regular bg-background-section-burn px-6 py-4">
<DialogClose render={<Button className="mr-2" />}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</DialogClose>
<Button type="submit" variant="primary">
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</form>
)
}

View File

@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { AppModeEnum } from '@/types/app'
import GetAutomaticRes from '../get-automatic-res'
import { GetAutomaticRes } from '../get-automatic-res'
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
@@ -72,8 +72,8 @@ describe('Prompt generator reflow', () => {
<QueryClientProvider client={queryClient}>
<GetAutomaticRes
mode={AppModeEnum.CHAT}
isShow
onClose={vi.fn()}
open
onOpenChange={vi.fn()}
onFinished={vi.fn()}
flowId={`reflow-${width}`}
isBasicMode

View File

@@ -0,0 +1,255 @@
import type { GenRes } from '@/service/debug'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { ReactFlowProvider } from 'reactflow'
import { page, userEvent } from 'vite-plus/test/browser'
import { cleanup, render } from 'vitest-browser-react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { HooksStoreContextProvider } from '@/app/components/workflow/hooks-store'
import CodeGenerateBtn from '@/app/components/workflow/nodes/_base/components/code-generator-button'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import PromptGeneratorBtn from '@/app/components/workflow/nodes/llm/components/prompt-generator-btn'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { FlowType } from '@/types/common'
const { get, post } = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
const { default: appDebug } = await import('@/i18n/locales/en-US/app-debug.json')
const { default: appGeneration } = await import('@/i18n/locales/en-US/app-generation.json')
const { default: common } = await import('@/i18n/locales/en-US/common.json')
return createReactI18nextMock({ ...appDebug, ...appGeneration, ...common })
})
vi.mock('@/service/base', () => ({
get,
post,
request: vi.fn(),
getPublic: vi.fn(),
getMarketplace: vi.fn(),
postPublic: vi.fn(),
postMarketplace: vi.fn(),
put: vi.fn(),
del: vi.fn(),
delPublic: vi.fn(),
patch: vi.fn(),
patchPublic: vi.fn(),
upload: vi.fn(),
ssePost: vi.fn(),
sseGet: vi.fn(),
sseGeneratorPost: vi.fn(),
handleStream: vi.fn(),
buildSigninUrlWithRedirect: vi.fn(),
isWebAppSigninPath: vi.fn(),
buildWebAppSigninUrlWithRedirect: vi.fn(),
}))
// Model selection and Monaco decoding are independent of generator session ownership.
// Keep the real instruction editor, Result, version menu and overwrite confirmation.
vi.mock(
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
() => ({
default: () => <button type="button">Test model</button>,
}),
)
vi.mock('@monaco-editor/react', () => {
function Editor({ value, options }: { value?: string; options?: { readOnly?: boolean } }) {
return <textarea aria-label="Generated code" value={value} readOnly={options?.readOnly} />
}
return {
Editor,
default: Editor,
DiffEditor: Editor,
useMonaco: () => null,
loader: { config: vi.fn() },
}
})
const clients: ReturnType<typeof createConsoleQueryWrapper>['queryClient'][] = []
const onGenerated = vi.fn()
const firstResult: GenRes = { modified: 'First generated result', message: '' }
const secondResult: GenRes = { modified: 'Second generated result', message: '' }
async function renderEntry(kind: 'prompt' | 'code') {
const { queryClient } = createConsoleQueryWrapper()
clients.push(queryClient)
queryClient.setQueryData(commonQueryKeys.defaultModel(ModelTypeEnum.textGeneration), {
data: { model: 'test-model', provider: { provider: 'test-provider' } },
})
queryClient.setQueryData(
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({
input: { params: { model_type: ModelTypeEnum.textGeneration } },
}),
{ data: [] },
)
queryClient.setQueryData(
consoleQuery.instructionGenerate.template.post.queryKey({
input: { body: { type: kind } },
}),
{ data: 'Explain the desired result.' },
)
return render(
<QueryClientTestProvider queryClient={queryClient}>
<NuqsTestingAdapter>
<WorkflowContextProvider>
<ReactFlowProvider>
<HooksStoreContextProvider
configsMap={{
flowId: 'generator-flow',
flowType: FlowType.appFlow,
fileSettings: {},
}}
>
<EventEmitterContextProvider>
{kind === 'prompt' ? (
<PromptGeneratorBtn
nodeId="prompt-node"
currentPrompt="Original prompt"
onGenerated={onGenerated}
/>
) : (
<CodeGenerateBtn
nodeId="code-node"
currentCode="return old_value"
codeLanguages={CodeLanguage.python3}
onGenerated={onGenerated}
/>
)}
</EventEmitterContextProvider>
</HooksStoreContextProvider>
</ReactFlowProvider>
</WorkflowContextProvider>
</NuqsTestingAdapter>
</QueryClientTestProvider>,
)
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
vi.clearAllMocks()
get.mockImplementation(async (path: string) => {
if (path === '/spec/schema-definitions' || path.startsWith('/workspaces/current/tools/'))
return []
throw new Error(`Unexpected GET: ${path}`)
})
post.mockResolvedValue(firstResult)
})
afterEach(async () => {
await cleanup()
clients.splice(0).forEach((client) => client.clear())
vi.unstubAllGlobals()
await page.viewport(1280, 900)
})
it.each(['prompt', 'code'] as const)(
'restores the %s entry, persisted instructions and selected version while discarding temporary ideal output',
async (kind) => {
const width = kind === 'code' ? 414 : 1280
await page.viewport(width, 900)
const screen = await renderEntry(kind)
const entry = screen.getByRole('button', { name: 'Generate', exact: true })
await entry.click()
const dialog = screen.getByRole('dialog', {
name: kind === 'code' ? 'Code Generator' : 'Prompt Generator',
exact: true,
})
await expect.element(dialog).toBeVisible()
const instructions = dialog.getByRole('textbox', { name: 'Instructions', exact: true })
const dismiss = dialog.getByRole('button', { name: 'Dismiss', exact: true })
await instructions.fill('Persist these instructions')
post.mockResolvedValueOnce(firstResult).mockResolvedValueOnce(secondResult)
const generate = dialog.getByRole('button', {
name: 'Generate',
exact: true,
})
await generate.click()
const apply = dialog.getByRole('button', { name: 'Apply', exact: true })
await expect.element(apply).toBeVisible()
await expect.poll(() => getComputedStyle(dialog.element()).opacity).toBe('1')
const bounds = dialog.element().getBoundingClientRect()
if (kind === 'code') {
const actionBounds = apply.element().getBoundingClientRect()
expect(bounds.left).toBeGreaterThanOrEqual(0)
expect(bounds.right).toBeLessThanOrEqual(width)
expect(actionBounds.left).toBeGreaterThanOrEqual(0)
expect(actionBounds.right).toBeLessThanOrEqual(width)
}
await generate.click()
await dialog.getByRole('button', { name: /Version 2/ }).click()
await screen.getByRole('menuitemradio', { name: 'Version 1', exact: true }).click()
await dialog.getByRole('button', { name: /Ideal Output/ }).click()
const ideal = dialog.getByRole('textbox', { name: 'Ideal Output', exact: true })
await ideal.fill('Temporary ideal output')
const popup = dialog.element()
const idealElement = ideal.element() as HTMLTextAreaElement
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
const exitDraft = new Promise<boolean>((resolve) => {
const observe = (event: Event) => {
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
popup.removeEventListener('transitionrun', observe)
resolve(popup.isConnected && idealElement.value === 'Temporary ideal output')
}
popup.addEventListener('transitionrun', observe)
})
await dismiss.click()
expect(await exitDraft).toBe(true)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(entry).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(instructions).toHaveTextContent('Persist these instructions')
await expect.element(dialog.getByRole('button', { name: /Version 1/ })).toBeVisible()
if (kind === 'code')
await expect
.element(dialog.getByRole('textbox', { name: 'Generated code' }))
.toHaveValue(firstResult.modified)
else await expect.element(dialog.getByText(firstResult.modified, { exact: true })).toBeVisible()
await dialog.getByRole('button', { name: /Ideal Output/ }).click()
await expect.element(ideal).toHaveValue('')
await apply.click()
const confirmation = screen.getByRole('alertdialog')
await expect.element(confirmation).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(confirmation).not.toBeInTheDocument()
await expect.element(dialog).toBeVisible()
await expect.element(apply).toHaveFocus()
expect(onGenerated).not.toHaveBeenCalled()
await apply.click()
await confirmation.getByRole('button', { name: 'Confirm', exact: true }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(entry).toHaveFocus()
expect(onGenerated).toHaveBeenCalledExactlyOnceWith(firstResult.modified)
},
)
it('allows closing the code generator while generation is pending without applying a late result', async () => {
await page.viewport(1280, 900)
let finish!: (result: GenRes) => void
post.mockImplementation(
() =>
new Promise<GenRes>((resolve) => {
finish = resolve
}),
)
const screen = await renderEntry('code')
const entry = screen.getByRole('button', { name: 'Generate', exact: true })
await entry.click()
const dialog = screen.getByRole('dialog', { name: 'Code Generator', exact: true })
await dialog.getByRole('textbox', { name: 'Instructions', exact: true }).fill('Generate later')
await dialog.getByRole('button', { name: 'Generate', exact: true }).click()
await expect.poll(() => post.mock.calls.length).toBe(1)
await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(entry).toHaveFocus()
finish(firstResult)
await userEvent.keyboard('{Enter}')
await expect
.element(dialog.getByRole('textbox', { name: 'Generated code' }))
.toHaveValue(firstResult.modified)
expect(onGenerated).not.toHaveBeenCalled()
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(entry).toHaveFocus()
})

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import GetAutomaticRes from '../get-automatic-res'
import { GetAutomaticRes } from '../get-automatic-res'
const mockGenerateBasicAppFirstTimeRule = vi.fn()
const mockGenerateRule = vi.fn()
@@ -133,8 +133,8 @@ describe('GetAutomaticRes', () => {
render(
<GetAutomaticRes
mode={AppModeEnum.CHAT}
isShow
onClose={mockOnClose}
open
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
flowId="flow-1"
isBasicMode
@@ -164,8 +164,8 @@ describe('GetAutomaticRes', () => {
render(
<GetAutomaticRes
mode={AppModeEnum.CHAT}
isShow
onClose={mockOnClose}
open
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
flowId="flow-1"
/>,
@@ -188,8 +188,8 @@ describe('GetAutomaticRes', () => {
render(
<GetAutomaticRes
mode={AppModeEnum.CHAT}
isShow
onClose={mockOnClose}
open
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
flowId="flow-1"
isBasicMode
@@ -215,8 +215,8 @@ describe('GetAutomaticRes', () => {
render(
<GetAutomaticRes
mode={AppModeEnum.CHAT}
isShow
onClose={mockOnClose}
open
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
flowId="flow-1"
isBasicMode
@@ -266,8 +266,8 @@ describe('GetAutomaticRes', () => {
render(
<GetAutomaticRes
mode={AppModeEnum.CHAT}
isShow
onClose={mockOnClose}
open
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
flowId="flow-1"
isBasicMode
@@ -303,8 +303,8 @@ describe('GetAutomaticRes', () => {
render(
<GetAutomaticRes
mode={AppModeEnum.ADVANCED_CHAT}
isShow
onClose={mockOnClose}
open
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
flowId="flow-1"
nodeId="node-1"
@@ -336,4 +336,74 @@ describe('GetAutomaticRes', () => {
expect(screen.queryByTestId('result-panel')).not.toBeInTheDocument()
})
it('allows pending generation to close and restores persisted results in the next session', async () => {
let resolveGeneration: (value: {
prompt: string
variables: string[]
opening_statement: string
}) => void = () => {}
mockGenerateBasicAppFirstTimeRule.mockReturnValue(
new Promise((resolve) => {
resolveGeneration = resolve
}),
)
const props = {
flowId: 'session-flow',
mode: AppModeEnum.CHAT,
isBasicMode: true,
onOpenChange: mockOnClose,
onFinished: mockOnFinished,
}
const { rerender } = render(<GetAutomaticRes {...props} open />)
fireEvent.click(screen.getByText('set-basic-instruction'))
fireEvent.click(screen.getByText('set-idea-output'))
fireEvent.click(screen.getByRole('button', { name: 'appGeneration.generate.generate' }))
expect(mockGenerateBasicAppFirstTimeRule).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: 'appGeneration.generate.generate' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'appGeneration.generate.dismiss' }))
expect(mockOnClose).toHaveBeenCalledWith(false, expect.anything())
rerender(<GetAutomaticRes {...props} open={false} />)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await act(async () =>
resolveGeneration({ prompt: 'late generated prompt', variables: [], opening_statement: '' }),
)
expect(sessionStorage.getItem('gen-data-session-flow-versions')).toContain(
'late generated prompt',
)
expect(mockOnFinished).not.toHaveBeenCalled()
rerender(<GetAutomaticRes {...props} open />)
expect(screen.getByTestId('basic-editor')).toHaveTextContent('basic instruction')
expect(screen.getByTestId('idea-output')).toBeEmptyDOMElement()
expect(screen.getByTestId('result-panel')).toHaveTextContent('late generated prompt')
expect(screen.getByRole('button', { name: 'appGeneration.generate.generate' })).toBeEnabled()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('restores the stored version index and delegates Apply closure to its owner', async () => {
sessionStorage.setItem(
'gen-data-session-flow-versions',
JSON.stringify([{ modified: 'older version' }, { modified: 'selected version' }]),
)
sessionStorage.setItem('gen-data-session-flow-version-index', '1')
render(
<GetAutomaticRes
isBasicMode
flowId="session-flow"
mode={AppModeEnum.CHAT}
open
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
/>,
)
expect(screen.getByTestId('result-panel')).toHaveTextContent('selected version')
fireEvent.click(screen.getByText('apply-result'))
const confirmation = screen.getByRole('alertdialog')
fireEvent.click(within(confirmation).getByRole('button', { name: 'common.operation.confirm' }))
expect(mockOnFinished).toHaveBeenCalledExactlyOnceWith({ modified: 'selected version' })
expect(mockOnClose).not.toHaveBeenCalled()
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})

View File

@@ -6,15 +6,21 @@ import type { GenRes } from '@/service/debug'
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useQuery } from '@tanstack/react-query'
import { useBoolean, useSessionStorageState } from 'ahooks'
import * as React from 'react'
@@ -41,8 +47,8 @@ const i18nPrefix = 'generate'
type IGetAutomaticResProps = {
mode: AppModeEnum
isShow: boolean
onClose: () => void
open: boolean
onOpenChange: (open: boolean) => void
onFinished: (res: GenRes) => void
flowId?: string
nodeId?: string
@@ -68,17 +74,15 @@ const TryLabel: FC<{
)
}
const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const GetAutomaticResContent = ({
mode,
isShow,
onClose,
flowId,
nodeId,
editorId,
currentPrompt,
isBasicMode,
onFinished,
}) => {
}: Omit<IGetAutomaticResProps, 'open' | 'onOpenChange'>) => {
const { t } = useTranslation(['appDebug', 'common', 'appGeneration'])
const instructionLabelId = useId()
const [storedModel, setStoredModel] = useAutoGenModel()
@@ -275,165 +279,159 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
}
return (
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent className="max-h-[calc(100dvh-2rem)] w-285 overflow-y-auto border-none p-0! text-left align-middle xl:h-[min(680px,calc(100dvh-2rem))] xl:overflow-hidden">
<div className="flex min-h-0 flex-col xl:h-full xl:flex-row">
<div className="min-w-0 border-divider-regular p-6 xl:h-full xl:flex-1 xl:overflow-y-auto xl:border-r">
<div className="mb-5">
<DialogTitle className={`text-lg leading-7 font-bold ${s.textGradient}`}>
{t(($) => $['generate.title'], { ns: 'appGeneration' })}
</DialogTitle>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">
{t(($) => $['generate.description'], { ns: 'appGeneration' })}
<div className="flex min-h-0 flex-col xl:h-full xl:flex-row">
<div className="min-w-0 border-divider-regular p-6 xl:h-full xl:flex-1 xl:overflow-y-auto xl:border-r">
<div className="mb-5">
<DialogTitle className={`text-lg leading-7 font-bold ${s.textGradient}`}>
{t(($) => $['generate.title'], { ns: 'appGeneration' })}
</DialogTitle>
<DialogDescription className="mt-1 text-[13px] font-normal text-text-tertiary">
{t(($) => $['generate.description'], { ns: 'appGeneration' })}
</DialogDescription>
</div>
<div>
<ModelParameterModal
popupClassName="w-130! max-w-[calc(100vw-2rem)]"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
{isBasicMode && (
<div className="mt-4">
<div className="flex items-center">
<div className="mr-3 shrink-0 text-xs leading-4.5 font-semibold text-text-tertiary uppercase">
{t(($) => $['generate.tryIt'], { ns: 'appGeneration' })}
</div>
<div
className="h-px grow"
style={{
background:
'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))',
}}
></div>
</div>
<div>
<ModelParameterModal
popupClassName="w-130! max-w-[calc(100vw-2rem)]"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
{isBasicMode && (
<div className="mt-4">
<div className="flex items-center">
<div className="mr-3 shrink-0 text-xs leading-4.5 font-semibold text-text-tertiary uppercase">
{t(($) => $['generate.tryIt'], { ns: 'appGeneration' })}
</div>
<div
className="h-px grow"
style={{
background:
'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))',
}}
></div>
</div>
<div className="flex flex-wrap">
{tryList.map((item) => (
<TryLabel
key={item.key}
iconClassName={item.iconClassName}
text={t(($) => $[`generate.template.${item.key}.name`], {
ns: 'appGeneration',
})}
onClick={handleChooseTemplate(item.key)}
/>
))}
</div>
</div>
)}
{/* inputs */}
<div className="mt-4">
<div>
<div
id={instructionLabelId}
className="mb-1.5 system-sm-semibold-uppercase text-text-secondary"
>
{t(($) => $['generate.instruction'], { ns: 'appGeneration' })}
</div>
{isBasicMode ? (
<InstructionEditorInBasic
aria-labelledby={instructionLabelId}
editorKey={instructionEditorKey}
generatorType={GeneratorType.prompt}
value={instruction}
onChange={setInstructionFromSessionStorage}
availableVars={[]}
availableNodes={[]}
isShowCurrentBlock={!!currentPrompt}
isShowLastRunBlock={false}
/>
) : (
<InstructionEditorInWorkflow
aria-labelledby={instructionLabelId}
editorKey={instructionEditorKey}
generatorType={GeneratorType.prompt}
value={instruction}
onChange={setInstructionFromSessionStorage}
nodeId={nodeId || ''}
isShowCurrentBlock={!!currentPrompt}
/>
)}
</div>
<IdeaOutput value={ideaOutput} onChange={setIdeaOutput} />
<div className="mt-7 flex flex-wrap justify-end gap-2">
<Button onClick={onClose}>
{t(($) => $[`${i18nPrefix}.dismiss`], { ns: 'appGeneration' })}
</Button>
<Button
className="flex"
variant="primary"
onClick={onGenerate}
disabled={isLoading}
>
<span aria-hidden className="i-custom-vender-other-generator size-4" />
<span className="text-xs font-semibold">
{t(($) => $['generate.generate'], { ns: 'appGeneration' })}
</span>
</Button>
</div>
<div className="flex flex-wrap">
{tryList.map((item) => (
<TryLabel
key={item.key}
iconClassName={item.iconClassName}
text={t(($) => $[`generate.template.${item.key}.name`], {
ns: 'appGeneration',
})}
onClick={handleChooseTemplate(item.key)}
/>
))}
</div>
</div>
)}
{!isLoading && current && (
<div className="min-w-0 bg-background-default-subtle p-6 pb-0 xl:h-full xl:flex-1">
<Result
current={current!}
isBasicMode={isBasicMode}
nodeId={nodeId!}
currentVersionIndex={currentVersionIndex || 0}
setCurrentVersionIndex={setCurrentVersionIndex}
versions={versions || []}
onApply={showConfirmOverwrite}
generatorType={GeneratorType.prompt}
/>
{/* inputs */}
<div className="mt-4">
<div>
<div
id={instructionLabelId}
className="mb-1.5 system-sm-semibold-uppercase text-text-secondary"
>
{t(($) => $['generate.instruction'], { ns: 'appGeneration' })}
</div>
)}
{isLoading && renderLoading}
{isShowAutoPromptResPlaceholder() && <ResPlaceholder />}
<AlertDialog
open={isShowConfirmOverwrite}
onOpenChange={(open) => !open && hideShowConfirmOverwrite()}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['generate.overwriteTitle'], { ns: 'appGeneration' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['generate.overwriteMessage'], { ns: 'appGeneration' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{isBasicMode ? (
<InstructionEditorInBasic
aria-labelledby={instructionLabelId}
editorKey={instructionEditorKey}
generatorType={GeneratorType.prompt}
value={instruction}
onChange={setInstructionFromSessionStorage}
availableVars={[]}
availableNodes={[]}
isShowCurrentBlock={!!currentPrompt}
isShowLastRunBlock={false}
/>
) : (
<InstructionEditorInWorkflow
aria-labelledby={instructionLabelId}
editorKey={instructionEditorKey}
generatorType={GeneratorType.prompt}
value={instruction}
onChange={setInstructionFromSessionStorage}
nodeId={nodeId || ''}
isShowCurrentBlock={!!currentPrompt}
/>
)}
</div>
<IdeaOutput value={ideaOutput} onChange={setIdeaOutput} />
<div className="mt-7 flex flex-wrap justify-end gap-2">
<DialogClose render={<Button />}>
{t(($) => $[`${i18nPrefix}.dismiss`], { ns: 'appGeneration' })}
</DialogClose>
<Button className="flex" variant="primary" onClick={onGenerate} disabled={isLoading}>
<span aria-hidden className="i-custom-vender-other-generator size-4" />
<span className="text-xs font-semibold">
{t(($) => $['generate.generate'], { ns: 'appGeneration' })}
</span>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{!isLoading && current && (
<div className="min-w-0 bg-background-default-subtle p-6 pb-0 xl:h-full xl:flex-1">
<Result
current={current!}
isBasicMode={isBasicMode}
nodeId={nodeId!}
currentVersionIndex={currentVersionIndex || 0}
setCurrentVersionIndex={setCurrentVersionIndex}
versions={versions || []}
onApply={showConfirmOverwrite}
generatorType={GeneratorType.prompt}
/>
</div>
)}
{isLoading && renderLoading}
{isShowAutoPromptResPlaceholder() && <ResPlaceholder />}
<AlertDialog
open={isShowConfirmOverwrite}
onOpenChange={(open) => !open && hideShowConfirmOverwrite()}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['generate.overwriteTitle'], { ns: 'appGeneration' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['generate.overwriteMessage'], { ns: 'appGeneration' })}
</AlertDialogDescription>
</div>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export default React.memo(GetAutomaticRes)
export const GetAutomaticRes = React.memo(
({ open, onOpenChange, ...props }: IGetAutomaticResProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100dvh-2rem)] w-285 overflow-y-auto border-none p-0! text-left align-middle xl:h-[min(680px,calc(100dvh-2rem))] xl:overflow-hidden">
<GetAutomaticResContent {...props} />
</DialogContent>
</Dialog>
),
)

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { AppModeEnum } from '@/types/app'
import GetCodeGeneratorResModal from '../get-code-generator-res'
import { GetCodeGeneratorResModal } from '../get-code-generator-res'
const mockGenerateRule = vi.fn()
const mockToastError = vi.fn()
@@ -128,9 +128,9 @@ describe('GetCodeGeneratorResModal', () => {
nodeId="node-1"
currentCode="print(1)"
mode={AppModeEnum.CHAT}
isShow
open
codeLanguages={CodeLanguage.python3}
onClose={mockOnClose}
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
/>,
)
@@ -155,9 +155,9 @@ describe('GetCodeGeneratorResModal', () => {
nodeId="node-1"
currentCode="print(1)"
mode={AppModeEnum.CHAT}
isShow
open
codeLanguages={CodeLanguage.python3}
onClose={mockOnClose}
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
/>,
)
@@ -182,9 +182,9 @@ describe('GetCodeGeneratorResModal', () => {
nodeId="node-1"
currentCode="print(1)"
mode={AppModeEnum.CHAT}
isShow
open
codeLanguages={CodeLanguage.python3}
onClose={mockOnClose}
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
/>,
)
@@ -209,9 +209,9 @@ describe('GetCodeGeneratorResModal', () => {
nodeId="node-1"
currentCode="print(1)"
mode={AppModeEnum.CHAT}
isShow
open
codeLanguages={CodeLanguage.python3}
onClose={mockOnClose}
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
/>,
)
@@ -264,9 +264,9 @@ describe('GetCodeGeneratorResModal', () => {
nodeId="node-1"
currentCode="print(1)"
mode={AppModeEnum.CHAT}
isShow
open
codeLanguages={CodeLanguage.python3}
onClose={mockOnClose}
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
/>,
)
@@ -304,9 +304,9 @@ describe('GetCodeGeneratorResModal', () => {
nodeId="node-1"
currentCode="print(1)"
mode={AppModeEnum.CHAT}
isShow
open
codeLanguages={CodeLanguage.javascript}
onClose={mockOnClose}
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
/>,
)
@@ -320,4 +320,70 @@ describe('GetCodeGeneratorResModal', () => {
expect(screen.queryByTestId('code-result-panel')).not.toBeInTheDocument()
})
it('allows pending generation to close and restores persisted results in the next session', async () => {
let resolveGeneration: (value: { code: string }) => void = () => {}
mockGenerateRule.mockReturnValue(
new Promise((resolve) => {
resolveGeneration = resolve
}),
)
const props = {
flowId: 'session-flow',
nodeId: 'node-1',
mode: AppModeEnum.CHAT,
codeLanguages: CodeLanguage.python3,
onOpenChange: mockOnClose,
onFinished: mockOnFinished,
}
const { rerender } = render(<GetCodeGeneratorResModal {...props} open />)
fireEvent.click(screen.getByText('set-code-instruction'))
fireEvent.click(screen.getByText('set-code-output'))
fireEvent.click(screen.getByRole('button', { name: 'appGeneration.codegen.generate' }))
expect(mockGenerateRule).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: 'appGeneration.codegen.generate' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'appGeneration.generate.dismiss' }))
expect(mockOnClose).toHaveBeenCalledWith(false, expect.anything())
rerender(<GetCodeGeneratorResModal {...props} open={false} />)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await act(async () => resolveGeneration({ code: 'late generated code' }))
expect(sessionStorage.getItem('gen-data-session-flow-node-1-versions')).toContain(
'late generated code',
)
expect(mockOnFinished).not.toHaveBeenCalled()
rerender(<GetCodeGeneratorResModal {...props} open />)
expect(screen.getByTestId('workflow-editor')).toHaveTextContent('code instruction')
expect(screen.getByTestId('idea-output')).toBeEmptyDOMElement()
expect(screen.getByTestId('code-result-panel')).toHaveTextContent('late generated code')
expect(screen.getByRole('button', { name: 'appGeneration.codegen.generate' })).toBeEnabled()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('restores the stored version index and delegates Apply closure to its owner', async () => {
sessionStorage.setItem(
'gen-data-session-flow-node-1-versions',
JSON.stringify([{ modified: 'older version' }, { modified: 'selected version' }]),
)
sessionStorage.setItem('gen-data-session-flow-node-1-version-index', '1')
render(
<GetCodeGeneratorResModal
codeLanguages={CodeLanguage.python3}
nodeId="node-1"
flowId="session-flow"
mode={AppModeEnum.CHAT}
open
onOpenChange={mockOnClose}
onFinished={mockOnFinished}
/>,
)
expect(screen.getByTestId('code-result-panel')).toHaveTextContent('selected version')
fireEvent.click(screen.getByText('apply-code-result'))
const confirmation = screen.getByRole('alertdialog')
fireEvent.click(within(confirmation).getByRole('button', { name: 'common.operation.confirm' }))
expect(mockOnFinished).toHaveBeenCalledExactlyOnceWith({ modified: 'selected version' })
expect(mockOnClose).not.toHaveBeenCalled()
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})

View File

@@ -1,23 +1,28 @@
import type { FC } from 'react'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type { GenRes } from '@/service/debug'
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useQuery } from '@tanstack/react-query'
import { useBoolean, useSessionStorageState } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/app/configuration/toast'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
@@ -52,23 +57,22 @@ type IGetCodeGeneratorResProps = {
nodeId: string
currentCode?: string
mode: AppModeEnum
isShow: boolean
open: boolean
codeLanguages: CodeLanguage
onClose: () => void
onOpenChange: (open: boolean) => void
onFinished: (res: GenRes) => void
}
export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ({
const GetCodeGeneratorResModalContent = ({
flowId,
nodeId,
currentCode,
mode,
isShow,
codeLanguages,
onClose,
onFinished,
}) => {
}: Omit<IGetCodeGeneratorResProps, 'open' | 'onOpenChange'>) => {
const { t } = useTranslation(['appDebug', 'common', 'appGeneration'])
const instructionLabelId = useId()
const [storedModel, setStoredModel] = useAutoGenModel()
const [selectedModel, setSelectedModel] = React.useState<Model>()
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(
@@ -188,7 +192,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ({
] = useBoolean(false)
const renderLoading = (
<div className="flex h-full w-0 grow flex-col items-center justify-center space-y-3">
<div className="flex min-w-0 flex-col items-center justify-center space-y-3 xl:h-full xl:flex-1">
<LoadingPlaceholder />
<div className="text-[13px] text-text-tertiary">
{t(($) => $['codegen.loading'], { ns: 'appGeneration' })}
@@ -197,115 +201,115 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ({
)
return (
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-full min-w-285 overflow-hidden! border-none p-0! text-left align-middle">
<div className="relative flex h-full min-h-0 flex-wrap">
<div className="h-full w-142.5 shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className={`text-lg leading-7 font-bold ${s.textGradient}`}>
{t(($) => $['codegen.title'], { ns: 'appGeneration' })}
<>
<div className="relative flex min-h-0 flex-col xl:h-full xl:flex-row">
<div className="min-w-0 border-divider-regular p-6 xl:h-full xl:flex-1 xl:overflow-y-auto xl:border-r">
<div className="mb-5">
<DialogTitle className={`text-lg leading-7 font-bold ${s.textGradient}`}>
{t(($) => $['codegen.title'], { ns: 'appGeneration' })}
</DialogTitle>
<DialogDescription className="mt-1 text-[13px] font-normal text-text-tertiary">
{t(($) => $['codegen.description'], { ns: 'appGeneration' })}
</DialogDescription>
</div>
<div className="mb-4">
<ModelParameterModal
popupClassName="w-130! max-w-[calc(100vw-2rem)]"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div>
<div className="text-[0px]">
<div
id={instructionLabelId}
className="mb-1.5 system-sm-semibold-uppercase text-text-secondary"
>
{t(($) => $['codegen.instruction'], { ns: 'appGeneration' })}
</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">
{t(($) => $['codegen.description'], { ns: 'appGeneration' })}
</div>
</div>
<div className="mb-4">
<ModelParameterModal
popupClassName="w-[520px]!"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
<InstructionEditor
aria-labelledby={instructionLabelId}
editorKey={instructionEditorKey}
value={instruction}
onChange={setInstructionFromSessionStorage}
nodeId={nodeId}
generatorType={GeneratorType.code}
isShowCurrentBlock={!!currentCode}
/>
</div>
<div>
<div className="text-[0px]">
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">
{t(($) => $['codegen.instruction'], { ns: 'appGeneration' })}
</div>
<InstructionEditor
editorKey={instructionEditorKey}
value={instruction}
onChange={setInstructionFromSessionStorage}
nodeId={nodeId}
generatorType={GeneratorType.code}
isShowCurrentBlock={!!currentCode}
/>
</div>
<IdeaOutput value={ideaOutput} onChange={setIdeaOutput} />
<IdeaOutput value={ideaOutput} onChange={setIdeaOutput} />
<div className="mt-7 flex justify-end space-x-2">
<Button onClick={onClose}>
{t(($) => $[`${i18nPrefix}.dismiss`], { ns: 'appGeneration' })}
</Button>
<Button
className="flex"
variant="primary"
onClick={onGenerate}
disabled={isLoading}
>
<span aria-hidden className="i-custom-vender-other-generator size-4" />
<span className="text-xs font-semibold">
{t(($) => $['codegen.generate'], { ns: 'appGeneration' })}
</span>
</Button>
</div>
<div className="mt-7 flex flex-wrap justify-end gap-2">
<DialogClose render={<Button />}>
{t(($) => $[`${i18nPrefix}.dismiss`], { ns: 'appGeneration' })}
</DialogClose>
<Button className="flex" variant="primary" onClick={onGenerate} disabled={isLoading}>
<span aria-hidden className="i-custom-vender-other-generator size-4" />
<span className="text-xs font-semibold">
{t(($) => $['codegen.generate'], { ns: 'appGeneration' })}
</span>
</Button>
</div>
</div>
{isLoading && renderLoading}
{!isLoading && !current && <ResPlaceholder />}
{!isLoading && current && (
<div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0">
<Result
current={current!}
currentVersionIndex={currentVersionIndex || 0}
setCurrentVersionIndex={setCurrentVersionIndex}
versions={versions || []}
onApply={showConfirmOverwrite}
generatorType={GeneratorType.code}
/>
</div>
)}
</div>
<AlertDialog
open={isShowConfirmOverwrite}
onOpenChange={(open) => !open && hideShowConfirmOverwrite()}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['codegen.overwriteConfirmTitle'], { ns: 'appGeneration' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['codegen.overwriteConfirmMessage'], { ns: 'appGeneration' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
{isLoading && renderLoading}
{!isLoading && !current && <ResPlaceholder />}
{!isLoading && current && (
<div className="min-w-0 bg-background-default-subtle p-6 pb-0 xl:h-full xl:flex-1">
<Result
current={current!}
currentVersionIndex={currentVersionIndex || 0}
setCurrentVersionIndex={setCurrentVersionIndex}
versions={versions || []}
onApply={showConfirmOverwrite}
generatorType={GeneratorType.code}
/>
</div>
)}
</div>
<AlertDialog
open={isShowConfirmOverwrite}
onOpenChange={(open) => !open && hideShowConfirmOverwrite()}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['codegen.overwriteConfirmTitle'], { ns: 'appGeneration' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['codegen.overwriteConfirmMessage'], { ns: 'appGeneration' })}
</AlertDialogDescription>
</div>
<AlertDialogFooter>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(GetCodeGeneratorResModal)
export const GetCodeGeneratorResModal = React.memo(
({ open, onOpenChange, ...props }: IGetCodeGeneratorResProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100dvh-2rem)] w-285 overflow-y-auto border-none p-0! text-left align-middle xl:h-[min(680px,calc(100dvh-2rem))] xl:overflow-hidden">
<GetCodeGeneratorResModalContent {...props} />
</DialogContent>
</Dialog>
),
)

View File

@@ -5,11 +5,11 @@ import type { InstallBundleCompleteCallback } from '@/app/components/plugins/ins
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -29,9 +29,9 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import Config from '@/app/components/app/configuration/config'
import EditHistoryModal from '@/app/components/app/configuration/config-prompt/conversation-history/edit-modal'
import AgentSettingButton from '@/app/components/app/configuration/config/agent-setting-button'
import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset'
import { EditModal as EditHistoryModal } from '@/app/components/app/configuration/config-prompt/conversation-history/edit-modal'
import { AgentSettingDialog } from '@/app/components/app/configuration/config/agent/agent-setting'
import { SelectDataSet } from '@/app/components/app/configuration/dataset-config/select-dataset'
import Debug from '@/app/components/app/configuration/debug'
import { FeaturesProvider } from '@/app/components/base/features'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
@@ -129,12 +129,12 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
</div>
<div className="flex items-center">
{isAgent && (
<AgentSettingButton
<AgentSettingDialog
isChatModel={contextValue.modelModeType === ModelModeType.chat}
agentConfig={modelConfig.agentConfig}
payload={modelConfig.agentConfig}
isFunctionCall={contextValue.isFunctionCall}
disabled={contextValue.readonly}
onAgentSettingChange={onAgentSettingChange}
onSave={onAgentSettingChange}
/>
)}
{!debugWithMultipleModel && (
@@ -208,7 +208,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
{t(($) => $['trailUseGPT4Info.description'], { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogFooter>
<AlertDialogCancelButton tone="default">
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
@@ -219,26 +219,23 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<SelectDataSet
isShow={isShowSelectDataSet}
onClose={onCloseSelectDataSet}
open={isShowSelectDataSet}
onOpenChange={(open) => !open && onCloseSelectDataSet()}
selectedIds={selectedIds}
onSelect={onSelectDataSets}
/>
{isShowHistoryModal && (
<EditHistoryModal
isShow={isShowHistoryModal}
saveLoading={false}
onClose={onCloseHistoryModal}
data={contextValue.completionPromptConfig.conversation_histories_role}
onSave={onSaveHistory}
/>
)}
<EditHistoryModal
open={isShowHistoryModal}
onOpenChange={(open) => !open && onCloseHistoryModal()}
data={contextValue.completionPromptConfig.conversation_histories_role}
onSave={onSaveHistory}
/>
{isMobile && (
<Drawer

View File

@@ -113,7 +113,7 @@ vi.mock('../card-item', () => ({
}))
vi.mock('../params-config', () => ({
default: ({ disabled, selectedDatasets }: any) => (
ParamsConfig: ({ disabled, selectedDatasets }: any) => (
<button data-testid="params-config" disabled={disabled}>
Params ({selectedDatasets.length})
</button>

View File

@@ -0,0 +1,294 @@
import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import { QueryClient } from '@tanstack/react-query'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { useState } from 'react'
import { page, userEvent } from 'vite-plus/test/browser'
import { cleanup, render } from 'vitest-browser-react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import AddDataset from '@/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset'
import ConfigContext, { useDebugConfigurationContext } from '@/context/debug-configuration'
import { RerankingModeEnum } from '@/models/datasets'
import { consoleQuery } from '@/service/console'
import { commonQueryKeys } from '@/service/use-common'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { seedSystemFeatures } from '@/test/console/query-data'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { seedWorkspacePermissionsQuery } from '@/test/console/workspace-permissions'
import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app'
import { useDatasetSelectHandler } from '../../hooks/configuration-lifecycle/dataset'
import DatasetConfig from '../index'
import { SelectDataSet } from '../select-dataset'
const { get, transport } = vi.hoisted(() => ({ get: vi.fn(), transport: vi.fn() }))
vi.mock('@/service/console/browser', () => ({ consoleBrowserLink: { call: transport } }))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useParams: () => ({}),
usePathname: () => '/app/example/configuration',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/service/base', () => ({
get,
post: vi.fn(),
request: vi.fn(),
getPublic: vi.fn(),
getMarketplace: vi.fn(),
postPublic: vi.fn(),
postMarketplace: vi.fn(),
put: vi.fn(),
del: vi.fn(),
delPublic: vi.fn(),
patch: vi.fn(),
patchPublic: vi.fn(),
upload: vi.fn(),
ssePost: vi.fn(),
sseGet: vi.fn(),
sseGeneratorPost: vi.fn(),
handleStream: vi.fn(),
buildSigninUrlWithRedirect: vi.fn(),
isWebAppSigninPath: vi.fn(),
buildWebAppSigninUrlWithRedirect: vi.fn(),
}))
const retrieval = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
}
const firstDataset: DataSet = {
id: 'economy',
name: 'Economy knowledge',
indexing_status: 'completed',
icon_info: { icon_type: 'emoji', icon: '📙', icon_background: '#FFF4ED', icon_url: '' },
description: '',
permission: 'all_team_members',
data_source_type: 'upload_file',
indexing_technique: 'economy',
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1,
app_count: 0,
doc_form: 'text_model',
document_count: 1,
total_document_count: 1,
word_count: 100,
provider: 'internal',
embedding_model: 'embedding',
embedding_model_provider: 'provider',
embedding_available: true,
retrieval_model_dict: retrieval,
retrieval_model: retrieval,
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: { top_k: 4, score_threshold: 0, score_threshold_enabled: false },
built_in_field_enabled: false,
runtime_mode: 'general',
enable_api: true,
is_multimodal: false,
}
const secondDataset: DataSet = {
...firstDataset,
id: 'quality',
name: 'Quality knowledge',
indexing_technique: 'high_quality',
}
const savedConfig: DatasetConfigs = {
retrieval_model: RETRIEVE_TYPE.multiWay,
reranking_model: retrieval.reranking_model,
top_k: 4,
score_threshold_enabled: false,
score_threshold: 0,
datasets: { datasets: [] },
reranking_enable: false,
reranking_mode: RerankingModeEnum.RerankingModel,
}
const clients: QueryClient[] = []
const ConfigProvider = ConfigContext.Provider
function WorkflowOwner() {
const [selected, setSelected] = useState<DataSet[]>(() => [firstDataset])
return <AddDataset selectedIds={selected.map((item) => item.id)} onChange={setSelected} />
}
function LegacyOwner() {
const defaults = useDebugConfigurationContext()
const [dataSets, setDataSets] = useState<DataSet[]>(() => [firstDataset])
const [datasetConfigs, setDatasetConfigs] = useState(() => savedConfig)
const [selectOpen, setSelectOpen] = useState(false)
const [rerankSettingModalOpen, setRerankSettingModalOpen] = useState(false)
const datasetConfigsRef = { current: datasetConfigs }
const handleSelect = useDatasetSelectHandler({
dataSets,
datasetConfigs,
datasetConfigsRef,
setDataSets,
setDatasetConfigs,
setRerankSettingModalOpen,
formattingChangedDispatcher: () => {},
hideSelectDataSet: () => setSelectOpen(false),
})
return (
<ConfigProvider
value={{
...defaults,
dataSets,
setDataSets,
datasetConfigs,
datasetConfigsRef,
setDatasetConfigs,
rerankSettingModalOpen,
setRerankSettingModalOpen,
showSelectDataSet: () => setSelectOpen(true),
}}
>
<DatasetConfig hideMetadataFilter />
<SelectDataSet
open={selectOpen}
onOpenChange={setSelectOpen}
selectedIds={dataSets.map((item) => item.id)}
onSelect={handleSelect}
/>
</ConfigProvider>
)
}
async function renderOwner(owner: 'workflow' | 'legacy') {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } },
})
clients.push(client)
seedAccountProfileQuery(client, { id: 'user-1' })
seedWorkspacePermissionsQuery(client, ['dataset.create_and_management'])
seedSystemFeatures(client, { rbac_enabled: true })
client.setQueryData(
consoleQuery.workspaces.current.models.modelTypes.byModelType.get.queryKey({
input: { params: { model_type: ModelTypeEnum.rerank } },
}),
{ data: [] },
)
client.setQueryData(commonQueryKeys.defaultModel(ModelTypeEnum.rerank), { data: null })
return render(
<QueryClientTestProvider queryClient={client}>
<NuqsTestingAdapter>
<div style={{ width: 640 }}>
{owner === 'workflow' ? <WorkflowOwner /> : <LegacyOwner />}
</div>
</NuqsTestingAdapter>
</QueryClientTestProvider>,
)
}
beforeEach(async () => {
await page.viewport(1280, 900)
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
get.mockReset()
get.mockImplementation(async (path: string) => {
if (path === '/datasets?page=1')
return { data: [firstDataset, secondDataset], page: 1, limit: 20, total: 2, has_more: false }
throw new Error(`Unexpected GET: ${path}`)
})
transport.mockReset()
transport.mockImplementation(async (path: readonly string[]) => {
throw new Error(`Unexpected Console request: ${path.join('.')}`)
})
})
afterEach(async () => {
await cleanup()
clients.splice(0).forEach((client) => client.clear())
vi.unstubAllGlobals()
})
it('preserves the workflow selection during exit and restores its keyboard entry before reopening fresh', async () => {
const screen = await renderOwner('workflow')
const entry = screen.getByRole('button', {
name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge',
})
await userEvent.keyboard('{Tab}')
await expect.element(entry).toHaveFocus()
expect(get).not.toHaveBeenCalled()
await userEvent.keyboard('{Enter}')
const dialog = screen.getByRole('dialog', { name: 'appDebug.feature.dataSet.selectTitle' })
await dialog.getByRole('button', { name: /Quality knowledge/ }).click()
await expect.element(dialog.getByText('2 appDebug.feature.dataSet.selected')).toBeVisible()
const popup = dialog.element()
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
const exitingDraft = new Promise<boolean>((resolve) => {
const observe = (event: Event) => {
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
popup.removeEventListener('transitionrun', observe)
resolve(
popup.isConnected &&
popup.textContent?.includes('2 appDebug.feature.dataSet.selected') === true,
)
}
popup.addEventListener('transitionrun', observe)
})
await dialog.getByRole('heading').hover()
await userEvent.keyboard('{Escape}')
expect(await exitingDraft).toBe(true)
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(entry).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(dialog.getByText('1 appDebug.feature.dataSet.selected')).toBeVisible()
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(entry).toHaveFocus()
})
it('keeps retrieval drafts through exit and transfers accepted mixed knowledge to the real correction session', async () => {
const screen = await renderOwner('legacy')
const retrievalEntry = screen.getByRole('button', { name: 'dataset.retrievalSettings' })
await retrievalEntry.click()
const retrievalDialog = screen.getByRole('dialog', { name: 'dataset.retrievalSettings' })
const topK = retrievalDialog.getByRole('textbox', { name: 'appDebug.datasetConfig.top_k' })
await topK.fill('7')
await userEvent.keyboard('{Tab}')
await expect.element(topK).toHaveValue('7')
const popup = retrievalDialog.element()
const input = topK.element() as HTMLInputElement
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
const exitingDraft = new Promise<boolean>((resolve) => {
const observe = (event: Event) => {
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
popup.removeEventListener('transitionrun', observe)
resolve(popup.isConnected && input.value === '7')
}
popup.addEventListener('transitionrun', observe)
})
await retrievalDialog.getByRole('button', { name: 'common.operation.cancel' }).hover()
await userEvent.keyboard('{Escape}')
expect(await exitingDraft).toBe(true)
await expect.element(retrievalDialog).not.toBeInTheDocument()
await expect.element(retrievalEntry).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(topK).toHaveValue('4')
await retrievalDialog.getByRole('button', { name: 'common.operation.cancel' }).click()
await expect.element(retrievalDialog).not.toBeInTheDocument()
const selectEntry = screen.getByRole('button', { name: 'common.operation.add', exact: true })
await selectEntry.click()
const selection = screen.getByRole('dialog', { name: 'appDebug.feature.dataSet.selectTitle' })
await selection.getByRole('button', { name: /Quality knowledge/ }).click()
await selection.getByRole('button', { name: 'common.operation.add', exact: true }).click()
await expect.element(selection).not.toBeInTheDocument()
await expect.element(retrievalDialog).toBeVisible()
await expect
.element(retrievalDialog.getByText('dataset.mixtureHighQualityAndEconomicTip'))
.toBeVisible()
await expect.poll(() => retrievalDialog.element().contains(document.activeElement)).toBe(true)
await expect.element(topK).toHaveValue('4')
await retrievalDialog.getByRole('button', { name: 'common.operation.cancel' }).click()
await expect.element(retrievalDialog).not.toBeInTheDocument()
await expect.element(screen.getByText('Quality knowledge', { exact: true })).toBeVisible()
await selectEntry.click()
await expect.element(selection.getByText('2 appDebug.feature.dataSet.selected')).toBeVisible()
})

View File

@@ -41,7 +41,7 @@ import { OperationButton } from '../base/operation-button'
import { useFormattingChangedDispatcher } from '../debug/hooks'
import CardItem from './card-item'
import ContextVar from './context-var'
import ParamsConfig from './params-config'
import { ParamsConfig } from './params-config'
type Props = Readonly<{
readonly?: boolean

View File

@@ -11,7 +11,7 @@ import {
import ConfigContext from '@/context/debug-configuration'
import { RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_TYPE } from '@/types/app'
import ParamsConfig from '../index'
import { ParamsConfig } from '../index'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(),
@@ -209,6 +209,46 @@ describe('dataset-config/params-config', () => {
expect(reopenedTopKInput)!.toHaveValue('4')
})
it('discards changes after Escape and starts the next session from persisted config', async () => {
const user = userEvent.setup()
renderParamsConfig()
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await user.click(
within(screen.getByRole('dialog')).getAllByRole('button', { name: /increment/i })[0]!,
)
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
expect(within(screen.getByRole('dialog')).getAllByRole('textbox')[0]).toHaveValue('4')
})
it('accepts external source changes while open without replacing the focused input', async () => {
const user = userEvent.setup()
const baseContext = {
datasetConfigs: createDatasetConfigs(),
setDatasetConfigs: vi.fn(),
rerankSettingModalOpen: true,
setRerankSettingModalOpen: vi.fn(),
} as unknown as React.ComponentProps<typeof ConfigContext.Provider>['value']
const { rerender } = render(
<ConfigContext.Provider value={baseContext}>
<ParamsConfig selectedDatasets={[]} />
</ConfigContext.Provider>,
)
const input = within(screen.getByRole('dialog')).getAllByRole('textbox')[0]!
await user.click(input)
const nextConfig = createDatasetConfigs({ top_k: 7 })
rerender(
<ConfigContext.Provider value={{ ...baseContext, datasetConfigs: nextConfig }}>
<ParamsConfig selectedDatasets={[]} />
</ConfigContext.Provider>,
)
expect(input).toHaveValue('7')
expect(input).toHaveFocus()
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(baseContext.setDatasetConfigs).toHaveBeenCalledWith(nextConfig)
})
it('should prevent saving when rerank model is required but invalid', async () => {
// Arrange
renderParamsConfig({

View File

@@ -3,11 +3,16 @@ import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { RiEqualizer2Line } from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@langgenius/dify-ui/dialog'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useContextSelector } from 'use-context-selector'
import { toast } from '@/app/components/app/configuration/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
@@ -24,15 +29,27 @@ type ParamsConfigProps = {
disabled?: boolean
selectedDatasets: DataSet[]
}
const ParamsConfig = ({ disabled, selectedDatasets }: ParamsConfigProps) => {
function ParamsConfigForm({
selectedDatasets,
open,
}: Pick<ParamsConfigProps, 'selectedDatasets'> & { open: boolean }) {
const { t } = useTranslation(['appDebug', 'common', 'dataset'])
const { datasetConfigs, setDatasetConfigs, rerankSettingModalOpen, setRerankSettingModalOpen } =
useContext(ConfigContext)
const datasetConfigs = useContextSelector(ConfigContext, (context) => context.datasetConfigs)
const setDatasetConfigs = useContextSelector(
ConfigContext,
(context) => context.setDatasetConfigs,
)
const setRerankSettingModalOpen = useContextSelector(
ConfigContext,
(context) => context.setRerankSettingModalOpen,
)
const [sourceConfigs, setSourceConfigs] = useState(datasetConfigs)
const [tempDataSetConfigs, setTempDataSetConfigs] = useState(datasetConfigs)
useEffect(() => {
if (open && sourceConfigs !== datasetConfigs) {
setSourceConfigs(datasetConfigs)
setTempDataSetConfigs(datasetConfigs)
}, [datasetConfigs])
}
const {
modelList: rerankModelList,
@@ -103,53 +120,66 @@ const ParamsConfig = ({ disabled, selectedDatasets }: ParamsConfigProps) => {
}
return (
<div>
<Button
variant="ghost"
size="small"
className={cn('h-7', rerankSettingModalOpen && 'bg-components-button-ghost-bg-hover')}
onClick={() => {
setRerankSettingModalOpen(true)
}}
disabled={disabled}
>
<RiEqualizer2Line className="size-3.5" />
<form
noValidate
onSubmit={(event) => {
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
handleSave()
}}
>
<DialogTitle className="sr-only">
{t(($) => $.retrievalSettings, { ns: 'dataset' })}
</Button>
{rerankSettingModalOpen && (
<Dialog
open={rerankSettingModalOpen}
onOpenChange={(open) => {
if (!open) {
setRerankSettingModalOpen(false)
}
}}
>
<DialogContent className="w-full max-w-120 border-none text-left align-middle sm:min-w-132">
<ConfigContent
datasetConfigs={tempDataSetConfigs}
onChange={handleSetTempDataSetConfigs}
selectedDatasets={selectedDatasets}
/>
<div className="mt-6 flex justify-end">
<Button
className="mr-2 shrink-0"
onClick={() => {
setTempDataSetConfigs(datasetConfigs)
setRerankSettingModalOpen(false)
}}
>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button variant="primary" className="shrink-0" onClick={handleSave}>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
</DialogTitle>
<ConfigContent
datasetConfigs={tempDataSetConfigs}
onChange={handleSetTempDataSetConfigs}
selectedDatasets={selectedDatasets}
/>
<div className="mt-6 flex justify-end">
<DialogClose render={<Button className="mr-2 shrink-0" />}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</DialogClose>
<Button type="submit" variant="primary" className="shrink-0">
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
</div>
</form>
)
}
export default memo(ParamsConfig)
export const ParamsConfig = memo(({ disabled, selectedDatasets }: ParamsConfigProps) => {
const { t } = useTranslation(['dataset'])
const rerankSettingModalOpen = useContextSelector(
ConfigContext,
(context) => context.rerankSettingModalOpen,
)
const setRerankSettingModalOpen = useContextSelector(
ConfigContext,
(context) => context.setRerankSettingModalOpen,
)
return (
<div>
<Dialog open={rerankSettingModalOpen} onOpenChange={setRerankSettingModalOpen}>
<DialogTrigger
disabled={disabled}
render={
<Button
variant="ghost"
size="small"
className={cn('h-7', rerankSettingModalOpen && 'bg-components-button-ghost-bg-hover')}
/>
}
>
<span aria-hidden className="i-ri-equalizer-2-line size-3.5" />
{t(($) => $.retrievalSettings, { ns: 'dataset' })}
</DialogTrigger>
<DialogContent className="w-full max-w-120 border-none text-left align-middle sm:min-w-132">
<ParamsConfigForm selectedDatasets={selectedDatasets} open={rerankSettingModalOpen} />
</DialogContent>
</Dialog>
</div>
)
})

View File

@@ -6,20 +6,20 @@ import { IndexingType } from '@/app/components/datasets/create/step-two'
import { DatasetPermission } from '@/models/datasets'
import { render } from '@/test/console/render'
import { RETRIEVE_METHOD } from '@/types/app'
import SelectDataSet from '../index'
import { SelectDataSet } from '../index'
const mockUseInfiniteScroll = vi.fn()
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal()
return {
...(typeof actual === 'object' && actual !== null ? actual : {}),
useInfiniteScroll: (...args: any[]) => mockUseInfiniteScroll(...args),
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
}
})
const mockUseInfiniteDatasets = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useInfiniteDatasets: (...args: any[]) => mockUseInfiniteDatasets(...args),
useInfiniteDatasets: (...args: unknown[]) => mockUseInfiniteDatasets(...args),
}))
let mockWorkspacePermissionKeys = ['dataset.create_and_management']
@@ -38,8 +38,8 @@ vi.mock('@/hooks/use-knowledge', () => ({
}))
const baseProps = {
isShow: true,
onClose: vi.fn(),
open: true,
onOpenChange: vi.fn(),
selectedIds: [] as string[],
onSelect: vi.fn(),
}
@@ -278,4 +278,29 @@ describe('SelectDataSet', () => {
expect(fetchNextPage).not.toHaveBeenCalled()
})
it('reads the latest persisted selection after close and ignores source changes within the open session', async () => {
const first = makeDataset({ id: 'set-1', name: 'Dataset One' })
const second = makeDataset({ id: 'set-2', name: 'Dataset Two' })
mockUseInfiniteDatasets.mockReturnValue({
data: { pages: [{ data: [first, second] }] },
isLoading: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
const onSelect = vi.fn()
const { rerender } = render(
<SelectDataSet {...baseProps} selectedIds={['set-1', 'set-2']} onSelect={onSelect} />,
)
rerender(<SelectDataSet {...baseProps} selectedIds={['set-1']} onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(onSelect).toHaveBeenLastCalledWith([first, second])
rerender(
<SelectDataSet {...baseProps} open={false} selectedIds={['set-1']} onSelect={onSelect} />,
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
rerender(<SelectDataSet {...baseProps} selectedIds={['set-1']} onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(onSelect).toHaveBeenLastCalledWith([first])
})
})

View File

@@ -0,0 +1,106 @@
import type { DataSet, DataSetListResponse } from '@/models/datasets'
import { queryOptions, skipToken } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vite-plus/test'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { SelectDataSet } from '../index'
const mockGet = vi.hoisted(() => vi.fn())
vi.mock('@/service/base', () => ({ get: mockGet, post: vi.fn() }))
const dataset = {
id: 'dataset-one',
name: 'Dataset One',
provider: 'internal',
indexing_technique: 'economy',
embedding_available: true,
icon_info: { icon_type: 'emoji', icon: '💾', icon_background: '#fff', icon_url: '' },
} as DataSet
const page = (pageNumber: number, hasMore: boolean): DataSetListResponse => ({
data: pageNumber === 1 ? [dataset] : [],
has_more: hasMore,
page: pageNumber,
limit: 20,
total: 21,
})
const datasetQuery = queryOptions({
queryKey: ['dataset', 'list', 'infinite', { page: 1 }],
queryFn: skipToken,
})
describe('dataset selector query lifetime', () => {
it('does not fetch while closed and refetches on each new session', async () => {
mockGet.mockReset().mockResolvedValue(page(1, false))
const { wrapper, queryClient } = createConsoleQueryWrapper()
const props = { onOpenChange: vi.fn(), onSelect: vi.fn(), selectedIds: [] }
const { rerender } = render(<SelectDataSet {...props} open={false} />, { wrapper })
expect(mockGet).not.toHaveBeenCalled()
rerender(<SelectDataSet {...props} open />)
expect(await screen.findByText('Dataset One')).toBeInTheDocument()
expect(mockGet).toHaveBeenCalledTimes(1)
rerender(<SelectDataSet {...props} open={false} />)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await act(async () => {
await queryClient.invalidateQueries({ queryKey: datasetQuery.queryKey })
})
expect(mockGet).toHaveBeenCalledTimes(1)
rerender(<SelectDataSet {...props} open />)
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2))
})
it('finishes refreshing cached page one before requesting the next page on reopen', async () => {
mockGet.mockReset()
let resolveRefresh: (response: DataSetListResponse) => void = () => {}
mockGet.mockImplementation((url: string) =>
url === '/datasets?page=1'
? new Promise<DataSetListResponse>((resolve) => {
resolveRefresh = resolve
})
: Promise.resolve(page(2, false)),
)
const { wrapper, queryClient } = createConsoleQueryWrapper()
queryClient.setQueryData(datasetQuery.queryKey, { pages: [page(1, true)], pageParams: [1] })
render(<SelectDataSet open onOpenChange={vi.fn()} onSelect={vi.fn()} selectedIds={[]} />, {
wrapper,
})
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/datasets?page=1'))
expect(mockGet.mock.calls.map(([url]) => url)).toEqual(['/datasets?page=1'])
await act(async () => resolveRefresh(page(1, false)))
expect(screen.getByText('Dataset One')).toBeInTheDocument()
expect(mockGet).toHaveBeenCalledTimes(1)
})
it('loads the next page on scroll after a cached refresh still reports more datasets', async () => {
mockGet.mockReset()
let resolveRefresh: (response: DataSetListResponse) => void = () => {}
mockGet.mockImplementation((url: string) =>
url === '/datasets?page=1'
? new Promise<DataSetListResponse>((resolve) => {
resolveRefresh = resolve
})
: Promise.resolve(page(2, false)),
)
const { wrapper, queryClient } = createConsoleQueryWrapper()
queryClient.setQueryData(datasetQuery.queryKey, { pages: [page(1, true)], pageParams: [1] })
render(<SelectDataSet open onOpenChange={vi.fn()} onSelect={vi.fn()} selectedIds={[]} />, {
wrapper,
})
const viewport = screen.getByRole('button', { name: /Dataset One/ }).parentElement!
// happy-dom has no layout; keep the scroll viewport away from its bottom until the scroll event.
Object.defineProperties(viewport, {
scrollHeight: { configurable: true, value: 1000 },
clientHeight: { configurable: true, value: 200 },
})
await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/datasets?page=1'))
expect(mockGet.mock.calls.map(([url]) => url)).toEqual(['/datasets?page=1'])
await act(async () => resolveRefresh(page(1, true)))
expect(mockGet.mock.calls.map(([url]) => url)).toEqual(['/datasets?page=1'])
fireEvent.scroll(viewport, { target: { scrollTop: 800 } })
await waitFor(() =>
expect(mockGet.mock.calls.map(([url]) => url)).toEqual([
'/datasets?page=1',
'/datasets?page=2',
]),
)
expect(screen.getByText('Dataset One')).toBeInTheDocument()
})
})

View File

@@ -1,5 +1,4 @@
'use client'
import type { FC } from 'react'
import type { DataSet } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@@ -7,8 +6,7 @@ import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dif
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useInfiniteScroll } from 'ahooks'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { memo, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
@@ -21,21 +19,19 @@ import Link from '@/next/link'
import { useInfiniteDatasets } from '@/service/knowledge/use-dataset'
import { hasPermission } from '@/utils/permission'
type ISelectDataSetProps = {
isShow: boolean
type SelectDataSetProps = {
open: boolean
modal?: boolean
onClose: () => void
onOpenChange: (open: boolean) => void
selectedIds: string[]
onSelect: (dataSet: DataSet[]) => void
}
const SelectDataSet: FC<ISelectDataSetProps> = ({
isShow,
modal,
onClose,
function SelectDataSetContent({
open,
selectedIds,
onSelect,
}) => {
}: Pick<SelectDataSetProps, 'open' | 'selectedIds' | 'onSelect'>) {
const { t } = useTranslation(['appDebug', 'common', 'dataset'])
const [selectedIdsInModal, setSelectedIdsInModal] = useState(() => selectedIds)
const canSelectMulti = true
@@ -44,7 +40,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets(
{ page: 1 },
{ enabled: isShow, staleTime: 0, refetchOnMount: 'always' },
{ enabled: open, staleTime: 0, refetchOnMount: 'always' },
)
const datasets = useMemo(() => {
const pages = data?.pages || []
@@ -63,8 +59,8 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
useInfiniteScroll(
async () => {
if (!hasNextPage || isFetchingNextPage) return { list: [] }
await fetchNextPage()
if (!open || !hasNextPage || isFetchingNextPage) return { list: [] }
await fetchNextPage({ cancelRefetch: false })
return { list: [] }
},
{
@@ -87,142 +83,137 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
onSelect(selected)
}
const handleClose = useCallback(() => {
setSelectedIdsInModal(selectedIds)
onClose()
}, [onClose, selectedIds])
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) handleClose()
},
[handleClose],
)
return (
<Dialog modal={modal} open={isShow} onOpenChange={handleOpenChange}>
<DialogContent backdropProps={{ forceRender: true }} className="w-100 overflow-hidden">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['feature.dataSet.selectTitle'], { ns: 'appDebug' })}
</DialogTitle>
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
{isLoading && datasets.length === 0 && (
<div className="flex h-50">
<LoadingPlaceholder />
</div>
)}
<>
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['feature.dataSet.selectTitle'], { ns: 'appDebug' })}
</DialogTitle>
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
{isLoading && datasets.length === 0 && (
<div className="flex h-50">
<LoadingPlaceholder />
</div>
)}
{hasNoData && (
<div className="mt-6 flex h-32 items-center justify-center space-x-1 rounded-lg border border-divider-subtle bg-components-panel-on-panel-item-bg text-[13px]">
<span className="text-text-tertiary">
{t(($) => $['feature.dataSet.noDataSet'], { ns: 'appDebug' })}
</span>
{canCreateDataset && (
<Link href="/datasets/create" className="font-normal text-text-accent">
{t(($) => $['feature.dataSet.toCreate'], { ns: 'appDebug' })}
</Link>
)}
</div>
)}
{hasNoData && (
<div className="mt-6 flex h-32 items-center justify-center space-x-1 rounded-lg border border-divider-subtle bg-components-panel-on-panel-item-bg text-[13px]">
<span className="text-text-tertiary">
{t(($) => $['feature.dataSet.noDataSet'], { ns: 'appDebug' })}
</span>
{canCreateDataset && (
<Link href="/datasets/create" className="font-normal text-text-accent">
{t(($) => $['feature.dataSet.toCreate'], { ns: 'appDebug' })}
</Link>
)}
</div>
)}
{datasets.length > 0 && (
<>
<div ref={listRef} className="mt-7 max-h-71.5 space-y-1 overflow-y-auto">
{datasets.map((item) => (
<button
key={item.id}
type="button"
disabled={!item.embedding_available}
className={cn(
'flex h-10 w-full cursor-pointer items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 text-left shadow-xs outline-hidden hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid',
selectedIdsInModal.includes(item.id) &&
'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs',
!item.embedding_available &&
'cursor-default hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs',
)}
onClick={() => toggleSelect(item)}
>
<div className="mr-1 flex grow items-center overflow-hidden">
<div className={cn('mr-2', !item.embedding_available && 'opacity-30')}>
<AppIcon
size="tiny"
iconType={item.icon_info.icon_type}
icon={item.icon_info.icon}
background={
item.icon_info.icon_type === 'image'
? undefined
: item.icon_info.icon_background
}
imageUrl={
item.icon_info.icon_type === 'image' ? item.icon_info.icon_url : undefined
}
/>
</div>
<div
className={cn(
'max-w-50 truncate text-[13px] font-medium text-text-secondary',
!item.embedding_available && 'max-w-30! opacity-30',
)}
>
{item.name}
</div>
{!item.embedding_available && (
<span className="ml-1 shrink-0 rounded-md border border-divider-deep px-1 text-xs leading-4.5 font-normal text-text-tertiary">
{t(($) => $.unavailable, { ns: 'dataset' })}
</span>
)}
</div>
{item.is_multimodal && (
<div className="mr-1 shrink-0">
<FeatureIcon feature={ModelFeatureEnum.vision} />
</div>
)}
{!!item.indexing_technique && (
<Badge
className="shrink-0"
text={formatIndexingTechniqueAndMethod(
item.indexing_technique,
item.retrieval_model_dict?.search_method,
)}
{datasets.length > 0 && (
<>
<div ref={listRef} className="mt-7 max-h-71.5 space-y-1 overflow-y-auto">
{datasets.map((item) => (
<button
key={item.id}
type="button"
disabled={!item.embedding_available}
className={cn(
'flex h-10 w-full cursor-pointer items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 text-left shadow-xs outline-hidden hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm focus-visible:ring-2 focus-visible:ring-state-accent-solid',
selectedIdsInModal.includes(item.id) &&
'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs',
!item.embedding_available &&
'cursor-default hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs',
)}
onClick={() => toggleSelect(item)}
>
<div className="mr-1 flex grow items-center overflow-hidden">
<div className={cn('mr-2', !item.embedding_available && 'opacity-30')}>
<AppIcon
size="tiny"
iconType={item.icon_info.icon_type}
icon={item.icon_info.icon}
background={
item.icon_info.icon_type === 'image'
? undefined
: item.icon_info.icon_background
}
imageUrl={
item.icon_info.icon_type === 'image' ? item.icon_info.icon_url : undefined
}
/>
</div>
<div
className={cn(
'max-w-50 truncate text-[13px] font-medium text-text-secondary',
!item.embedding_available && 'max-w-30! opacity-30',
)}
>
{item.name}
</div>
{!item.embedding_available && (
<span className="ml-1 shrink-0 rounded-md border border-divider-deep px-1 text-xs leading-4.5 font-normal text-text-tertiary">
{t(($) => $.unavailable, { ns: 'dataset' })}
</span>
)}
{item.provider === 'external' && (
<Badge className="shrink-0" text={t(($) => $.externalTag, { ns: 'dataset' })} />
)}
</button>
))}
{isFetchingNextPage && <LoadingPlaceholder />}
</div>
</>
)}
{!isLoading && (
<div className="mt-8 flex items-center justify-between">
<div className="text-sm font-medium text-text-secondary">
{selected.length > 0 &&
`${selected.length} ${t(($) => $['feature.dataSet.selected'], { ns: 'appDebug' })}`}
</div>
<div className="flex space-x-2">
<Button onClick={handleClose}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</Button>
<Button variant="primary" onClick={handleSelect} disabled={hasNoData}>
{t(($) => $['operation.add'], { ns: 'common' })}
</Button>
</div>
</div>
{item.is_multimodal && (
<div className="mr-1 shrink-0">
<FeatureIcon feature={ModelFeatureEnum.vision} />
</div>
)}
{!!item.indexing_technique && (
<Badge
className="shrink-0"
text={formatIndexingTechniqueAndMethod(
item.indexing_technique,
item.retrieval_model_dict?.search_method,
)}
/>
)}
{item.provider === 'external' && (
<Badge className="shrink-0" text={t(($) => $.externalTag, { ns: 'dataset' })} />
)}
</button>
))}
{isFetchingNextPage && <LoadingPlaceholder />}
</div>
)}
</DialogContent>
</Dialog>
</>
)}
{!isLoading && (
<div className="mt-8 flex items-center justify-between">
<div className="text-sm font-medium text-text-secondary">
{selected.length > 0 &&
`${selected.length} ${t(($) => $['feature.dataSet.selected'], { ns: 'appDebug' })}`}
</div>
<div className="flex space-x-2">
<DialogClose render={<Button />}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</DialogClose>
<Button variant="primary" onClick={handleSelect} disabled={hasNoData}>
{t(($) => $['operation.add'], { ns: 'common' })}
</Button>
</div>
</div>
)}
</>
)
}
export default React.memo(SelectDataSet)
export const SelectDataSet = memo(
({ open, modal, onOpenChange, selectedIds, onSelect }: SelectDataSetProps) => (
<Dialog modal={modal} open={open} onOpenChange={onOpenChange}>
<DialogContent backdropProps={{ forceRender: true }} className="w-100 overflow-hidden">
<SelectDataSetContent open={open} selectedIds={selectedIds} onSelect={onSelect} />
</DialogContent>
</Dialog>
),
)

View File

@@ -182,7 +182,13 @@ vi.mock('@/app/components/app/text-generate/item', () => ({
}))
vi.mock('@/app/components/base/agent-log-modal', () => ({
default: ({ onCancel, currentLogItem }: { onCancel: () => void; currentLogItem: IChatItem }) => (
AgentLogModal: ({
onCancel,
currentLogItem,
}: {
onCancel: () => void
currentLogItem: IChatItem
}) => (
<div data-testid="agent-log-modal" role="dialog" aria-label="Agent log">
{currentLogItem.content}
<button type="button" data-testid="agent-log-cancel" onClick={onCancel}>

Some files were not shown because too many files have changed in this diff Show More