Compare commits
49 Commits
main
...
codex/dial
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f36245ee35 | ||
|
|
1559298336 | ||
|
|
dd9605b33b | ||
|
|
ec9969c3e6 | ||
|
|
399c478dd9 | ||
|
|
10131b005b | ||
|
|
596658fcd6 | ||
|
|
4c0ed90a43 | ||
|
|
669759eed7 | ||
|
|
7f6966524a | ||
|
|
71a13d2f80 | ||
|
|
03782a5b7d | ||
|
|
dd3f90a727 | ||
|
|
31fad26a29 | ||
|
|
58cad24590 | ||
|
|
79df3b5ab5 | ||
|
|
1ec48df6e2 | ||
|
|
17e820ddaf | ||
|
|
5258ca8814 | ||
|
|
6aafcf2f66 | ||
|
|
763d4a3070 | ||
|
|
303f6c5a5c | ||
|
|
7bc72e53e2 | ||
|
|
16b9586abc | ||
|
|
82cec802fe | ||
|
|
01a9274491 | ||
|
|
c1ae342517 | ||
|
|
270f41a5b1 | ||
|
|
5a36b938d1 | ||
|
|
e0f132dc8a | ||
|
|
5ae6e037b5 | ||
|
|
ae83debcd4 | ||
|
|
f3db46943a | ||
|
|
dcb40f809a | ||
|
|
cc3e3ff4ae | ||
|
|
3b6532c26b | ||
|
|
2cd1d49652 | ||
|
|
b5fa1120d8 | ||
|
|
9db88d7be0 | ||
|
|
bc03de3879 | ||
|
|
aeabc62704 | ||
|
|
8731e9531e | ||
|
|
a439622bde | ||
|
|
5af2696fce | ||
|
|
b40af0240d | ||
|
|
bf0e797c89 | ||
|
|
c6c5039f23 | ||
|
|
eb987d0c81 | ||
|
|
37c550095c |
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
12
packages/dify-ui/src/alert-dialog/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/alert-dialog/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
packages/dify-ui/src/autocomplete/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/autocomplete/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
12
packages/dify-ui/src/combobox/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/combobox/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.')
|
||||
|
||||
12
packages/dify-ui/src/dialog/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/dialog/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
12
packages/dify-ui/src/drawer/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/drawer/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
packages/dify-ui/src/field/__tests__/types.tsx
Normal file
10
packages/dify-ui/src/field/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
12
packages/dify-ui/src/popover/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/popover/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
12
packages/dify-ui/src/preview-card/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/preview-card/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
12
packages/dify-ui/src/select/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/select/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
12
packages/dify-ui/src/tooltip/__tests__/types.tsx
Normal file
12
packages/dify-ui/src/tooltip/__tests__/types.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user