Compare commits
31 Commits
main
...
codex/dial
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -276,11 +276,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
|
||||
@@ -766,7 +761,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 +880,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 +926,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
|
||||
@@ -1440,14 +1422,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 +1430,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 +1793,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": {
|
||||
@@ -2448,11 +2406,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 +2979,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 +3186,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 +3255,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
|
||||
@@ -4270,7 +4197,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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import type { ApiKeyItem } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -245,14 +245,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
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { DynamicOptions, Loader } from 'next/dynamic'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
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),
|
||||
)
|
||||
}
|
||||
},
|
||||
const { loadDynamic, observeImportOpen } = vi.hoisted(() => ({
|
||||
loadDynamic: vi.fn(),
|
||||
observeImportOpen: vi.fn(),
|
||||
}))
|
||||
|
||||
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/app/switch-app-modal', () => ({
|
||||
default: ({ show, onClose }: { show: boolean; onClose: () => void }) =>
|
||||
show ? (
|
||||
@@ -61,16 +70,27 @@ vi.mock('@/app/components/app/duplicate-modal', () => ({
|
||||
}))
|
||||
|
||||
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', () => ({
|
||||
@@ -146,6 +166,24 @@ 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} />)
|
||||
|
||||
@@ -7,11 +7,11 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-moda
|
||||
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'
|
||||
@@ -32,9 +32,13 @@ const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app
|
||||
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const UpdateDSLDialog = dynamic(
|
||||
() =>
|
||||
import('@/app/components/workflow/update-dsl-modal').then((module) => module.UpdateDSLDialog),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
)
|
||||
|
||||
type AppInfoModalsProps = {
|
||||
appDetail: AppDetailWithSite
|
||||
@@ -67,6 +71,10 @@ const AppInfoModals = ({
|
||||
}: AppInfoModalsProps) => {
|
||||
const { t } = useTranslation(['app', 'common', 'workflow'])
|
||||
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
|
||||
const [hasActivatedImportDialog, setHasActivatedImportDialog] = useState(
|
||||
() => activeModal === 'importDSL',
|
||||
)
|
||||
if (activeModal === 'importDSL' && !hasActivatedImportDialog) setHasActivatedImportDialog(true)
|
||||
const exportConfirmLabelId = React.useId()
|
||||
const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name
|
||||
const exportDialogMode =
|
||||
@@ -185,22 +193,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 +234,7 @@ const AppInfoModals = ({
|
||||
{t(($) => $['sidebar.exportWarningDesc'], { ns: 'workflow' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -239,7 +250,7 @@ const AppInfoModals = ({
|
||||
: t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</span>
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
@@ -238,14 +238,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 () => {
|
||||
|
||||
@@ -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'
|
||||
@@ -223,14 +223,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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -30,10 +30,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',
|
||||
() => ({
|
||||
|
||||
@@ -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'
|
||||
@@ -411,7 +411,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 +423,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Panel>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ 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'
|
||||
@@ -416,7 +416,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
{t(($) => $['generate.overwriteMessage'], { ns: 'appGeneration' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -428,7 +428,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,11 @@ 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'
|
||||
@@ -288,7 +288,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ({
|
||||
{t(($) => $['codegen.overwriteConfirmMessage'], { ns: 'appGeneration' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -300,7 +300,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = ({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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'
|
||||
@@ -30,7 +30,7 @@ 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 { 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'
|
||||
@@ -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,7 +219,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ vi.mock('../debug-with-single-model', () => ({
|
||||
),
|
||||
}))
|
||||
vi.mock('@/app/components/base/chat/chat/answer', async () => {
|
||||
const { default: Operation } = await import('@/app/components/base/chat/chat/answer/operation')
|
||||
const { Operation } = await import('@/app/components/base/chat/chat/answer/operation')
|
||||
return {
|
||||
default: ({ item }: { item: ChatItem }) => (
|
||||
<div className="group">
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '#i18n'
|
||||
import { toast } from '@/app/components/app/configuration/toast'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
|
||||
import { IconPickerDialog } from '@/app/components/base/icon-picker'
|
||||
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
@@ -45,7 +44,6 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
const docLink = useDocLink()
|
||||
const locale = useLocale()
|
||||
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool')
|
||||
|
||||
const providers = buildProviders({
|
||||
@@ -156,20 +154,26 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
t(($) => $['feature.tools.modal.name.placeholder'], { ns: 'appDebug' }) || ''
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<IconPickerDialog
|
||||
aria-label={t(($) => $['iconPicker.title'], { ns: 'app' })}
|
||||
className="shrink-0 cursor-pointer rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setShowEmojiPicker(true)}
|
||||
>
|
||||
<AppIcon
|
||||
decorative
|
||||
size="large"
|
||||
className="h-9! w-9! rounded-lg border-[0.5px] border-components-panel-border"
|
||||
icon={localeData.icon}
|
||||
background={localeData.icon_background}
|
||||
/>
|
||||
</button>
|
||||
size="large"
|
||||
iconClassName="h-9! w-9! rounded-lg border-[0.5px] border-components-panel-border"
|
||||
enableImageUpload={false}
|
||||
value={
|
||||
localeData.icon
|
||||
? {
|
||||
type: 'emoji',
|
||||
icon: localeData.icon,
|
||||
background: localeData.icon_background,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onConfirm={(value) => {
|
||||
if (value.type !== 'emoji') return
|
||||
const { icon, background: icon_background } = value
|
||||
handleValueChange({ icon, icon_background })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
@@ -223,25 +227,6 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
<IconPickerDialog
|
||||
enableImageUpload={false}
|
||||
defaultValue={
|
||||
localeData.icon
|
||||
? {
|
||||
type: 'emoji',
|
||||
icon: localeData.icon,
|
||||
background: localeData.icon_background,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
open={showEmojiPicker}
|
||||
onOpenChange={setShowEmojiPicker}
|
||||
onConfirm={(value) => {
|
||||
if (value.type !== 'emoji') return
|
||||
const { icon, background: icon_background } = value
|
||||
handleValueChange({ icon, icon_background })
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AppDetail } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import { HotkeyManager } from '@tanstack/react-hotkeys'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
@@ -124,6 +125,7 @@ function render(ui: ReactElement) {
|
||||
describe('CreateAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
HotkeyManager.resetInstance()
|
||||
mockUseRouter.mockReturnValue({ push: mockPush } as unknown as ReturnType<typeof useRouter>)
|
||||
appQuota = { size: 0, limit: 1 }
|
||||
mockConsoleStateReader.mockReturnValue({
|
||||
@@ -242,45 +244,91 @@ describe('CreateAppModal', () => {
|
||||
expect(onCreateFromTemplate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a beginner chat app with the keyboard shortcut and selected icon style', async () => {
|
||||
mockCreateApp.mockResolvedValue(
|
||||
createAppDetailFixture({ id: 'chat-app', mode: AppModeEnum.CHAT }),
|
||||
)
|
||||
renderModal()
|
||||
it.each([
|
||||
{ platform: 'Win32', modifier: { ctrlKey: true } },
|
||||
{ platform: 'MacIntel', modifier: { metaKey: true } },
|
||||
])(
|
||||
'creates a beginner chat app from its scoped $platform shortcut and selected icon style',
|
||||
async ({ platform, modifier }) => {
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue(platform)
|
||||
mockCreateApp.mockResolvedValue(
|
||||
createAppDetailFixture({ id: 'chat-app', mode: AppModeEnum.CHAT }),
|
||||
)
|
||||
renderModal()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.forBeginners'))
|
||||
fireEvent.click(screen.getByText('app.types.chatbot'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'app.iconPicker.color.green' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
|
||||
target: { value: 'Keyboard App' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), {
|
||||
target: { value: 'Created from shortcut' },
|
||||
})
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter', code: 'Enter', ctrlKey: true })
|
||||
fireEvent.keyUp(nameInput, { key: 'Enter', code: 'Enter', ctrlKey: true })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateApp).toHaveBeenCalledWith({
|
||||
name: 'Keyboard App',
|
||||
description: 'Created from shortcut',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#F3FEE7',
|
||||
mode: AppModeEnum.CHAT,
|
||||
fireEvent.click(screen.getByText('app.newApp.forBeginners'))
|
||||
fireEvent.click(screen.getByText('app.types.chatbot'))
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
|
||||
target: { value: 'Keyboard App' },
|
||||
})
|
||||
})
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
const pickerSearch = screen.getByPlaceholderText('app.iconPicker.search')
|
||||
fireEvent.keyDown(pickerSearch, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
fireEvent.keyUp(pickerSearch, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
expect(mockCreateApp).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'app.iconPicker.color.green' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), {
|
||||
target: { value: 'Created from shortcut' },
|
||||
})
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
fireEvent.keyUp(nameInput, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateApp).toHaveBeenCalledWith({
|
||||
name: 'Keyboard App',
|
||||
description: 'Created from shortcut',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#F3FEE7',
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ platform: 'Win32', modifier: { ctrlKey: true } },
|
||||
{ platform: 'MacIntel', modifier: { metaKey: true } },
|
||||
])(
|
||||
're-registers the $platform shortcut after the outer dialog remounts its content',
|
||||
async ({ platform, modifier }) => {
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue(platform)
|
||||
mockCreateApp.mockResolvedValue(
|
||||
createAppDetailFixture({ id: 'reopened-app', mode: AppModeEnum.ADVANCED_CHAT }),
|
||||
)
|
||||
const onClose = vi.fn()
|
||||
const modal = (show: boolean) => (
|
||||
<CreateAppModal
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
onCreateFromTemplate={vi.fn()}
|
||||
defaultAppMode={AppModeEnum.ADVANCED_CHAT}
|
||||
/>
|
||||
)
|
||||
const { rerender } = render(modal(true))
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
|
||||
rerender(modal(false))
|
||||
await waitFor(() => expect(picker).not.toBeInTheDocument())
|
||||
rerender(modal(true))
|
||||
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'Reopened App' } })
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
fireEvent.keyUp(nameInput, { key: 'Enter', code: 'Enter', ...modifier })
|
||||
await waitFor(() => expect(mockCreateApp).toHaveBeenCalledOnce())
|
||||
expect(mockCreateApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'Reopened App' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps creation unavailable from both the button and shortcut without a name', () => {
|
||||
renderModal()
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useDebounceFn } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IconPickerDialog } from '@/app/components/base/icon-picker'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { toast } from '@/app/notifications'
|
||||
@@ -60,7 +59,6 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
})
|
||||
const [showIconPicker, setShowIconPicker] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(() =>
|
||||
@@ -178,7 +176,7 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
},
|
||||
{
|
||||
target: contentRef,
|
||||
enabled: !createDisabled && !isCreating && !showIconPicker,
|
||||
enabled: !createDisabled && !isCreating,
|
||||
ignoreInputs: false,
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
@@ -324,26 +322,10 @@ function CreateApp({ onClose, onCreateFromTemplate, defaultAppMode }: CreateAppP
|
||||
placeholder={t(($) => $['newApp.appNamePlaceholder'], { ns: 'app' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['iconPicker.title'], { ns: 'app' })}
|
||||
className="shrink-0 cursor-pointer rounded-2xl focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setShowIconPicker(true)}
|
||||
>
|
||||
<AppIcon
|
||||
decorative
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId}
|
||||
background={appIcon.type === 'emoji' ? appIcon.background : undefined}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
size="xxl"
|
||||
className="rounded-2xl"
|
||||
/>
|
||||
</button>
|
||||
<IconPickerDialog
|
||||
open={showIconPicker}
|
||||
defaultValue={appIcon}
|
||||
onOpenChange={setShowIconPicker}
|
||||
value={appIcon}
|
||||
size="xxl"
|
||||
iconClassName="rounded-2xl"
|
||||
onConfirm={(payload) => {
|
||||
setAppIcon(payload)
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -58,7 +58,7 @@ function DSLConfirmModal({
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton variant="secondary" disabled={confirmLoading}>
|
||||
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -69,7 +69,7 @@ function DSLConfirmModal({
|
||||
>
|
||||
{t(($) => $['newApp.Confirm'], { ns: 'app' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -52,14 +52,14 @@ export function UndeployConfirmDialog({
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton variant="secondary" className="min-w-20" disabled={isPending}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton className="min-w-20" loading={isPending} onClick={onConfirm}>
|
||||
{t(($) => $['deployTab.confirmUndeploy'])}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
renderWithConsoleQuery,
|
||||
seedFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import { mockEmojiData } from '@/test/emoji-picker'
|
||||
import DuplicateAppModal from '../index'
|
||||
|
||||
mockEmojiData()
|
||||
|
||||
const { mockAppQuota, toastErrorMock } = vi.hoisted(() => ({
|
||||
mockAppQuota: { size: 0, limit: 1 },
|
||||
toastErrorMock: vi.fn(),
|
||||
@@ -20,56 +23,10 @@ vi.mock('@/app/notifications', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: () => <span>app-icon</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
|
||||
default: () => <div>apps-full</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icon-picker', () => ({
|
||||
IconPickerDialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (payload: { type: 'emoji'; icon: string; background: string }) => void
|
||||
}) => {
|
||||
if (!open) return null
|
||||
let selectedBackground = '#FFEAD5'
|
||||
return (
|
||||
<div>
|
||||
<input placeholder="Search emojis..." />
|
||||
<button type="button" onClick={() => {}}>
|
||||
<span>😀</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="#E4FBCC"
|
||||
onClick={() => {
|
||||
selectedBackground = '#E4FBCC'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onConfirm({ type: 'emoji', icon: '🤖', background: selectedBackground })
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
iconPicker.ok
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
iconPicker.cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return renderWithConsoleQuery(ui, {
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
@@ -161,7 +118,7 @@ describe('DuplicateAppModal', () => {
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update the selected icon before confirming the duplicate', async () => {
|
||||
it('keeps the picker independent of the name field and submits only its confirmed icon', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onHide = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
@@ -180,20 +137,22 @@ describe('DuplicateAppModal', () => {
|
||||
|
||||
await user.click(getIconButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: '#E4FBCC', hidden: true }))
|
||||
await user.click(screen.getByRole('radio', { name: 'app.iconPicker.color.green' }))
|
||||
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/, hidden: true }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('textbox', { name: /appCustomize\.subTitle/ })).toHaveValue('Demo App')
|
||||
await user.click(screen.getByRole('button', { name: /(?:^|\.)duplicate(?=$|:)/ }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
name: 'Demo App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
})
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
@@ -280,33 +239,16 @@ describe('DuplicateAppModal', () => {
|
||||
)
|
||||
|
||||
await user.click(getIconButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
|
||||
})
|
||||
const emojiButton = screen.getByText('😀').closest('button')
|
||||
expect(emojiButton).toBeTruthy()
|
||||
await user.click(emojiButton!)
|
||||
await user.click(screen.getByRole('button', { name: '#E4FBCC', hidden: true }))
|
||||
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/, hidden: true }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
|
||||
})
|
||||
await user.click(getIconButton())
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/, hidden: true }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument()
|
||||
})
|
||||
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(picker).not.toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: /(?:^|\.)duplicate(?=$|:)/ }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Image App',
|
||||
icon_type: 'emoji',
|
||||
icon: expect.any(String),
|
||||
icon_background: '#E4FBCC',
|
||||
icon_type: 'image',
|
||||
icon: 'original-file',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { AppDetailWithSite, CopyAppPayload } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { IconPickerDefaultValue, IconPickerValue } from '@/app/components/base/icon-picker'
|
||||
import type { IconPickerInputValue, IconPickerValue } from '@/app/components/base/icon-picker'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
@@ -11,7 +11,6 @@ import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IconPickerDialog } from '@/app/components/base/icon-picker'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { toast } from '@/app/notifications'
|
||||
@@ -43,15 +42,16 @@ const DuplicateAppModal = ({
|
||||
|
||||
const [name, setName] = React.useState(appName)
|
||||
|
||||
const [showIconPicker, setShowIconPicker] = useState(false)
|
||||
const [selectedIcon, setSelectedIcon] = useState<IconPickerValue | null>(null)
|
||||
const pickerDefaultValue: IconPickerDefaultValue | undefined =
|
||||
const pickerValue: IconPickerInputValue | undefined =
|
||||
selectedIcon ??
|
||||
(icon_type === 'image' && icon
|
||||
? { type: 'image', url: icon_url ?? '', fileId: icon }
|
||||
: icon_type === 'emoji' && icon
|
||||
? { type: 'emoji', icon, background: icon_background }
|
||||
: undefined)
|
||||
: icon_type === 'link' && icon
|
||||
? { type: 'link', url: icon }
|
||||
: undefined)
|
||||
const currentIcon = selectedIcon
|
||||
? {
|
||||
icon_type: selectedIcon.type,
|
||||
@@ -119,38 +119,27 @@ const DuplicateAppModal = ({
|
||||
}}
|
||||
>
|
||||
<div className="mb-9 system-sm-regular text-text-secondary">
|
||||
<Field name="name">
|
||||
<FieldLabel className="system-md-medium">
|
||||
{t(($) => $['appCustomize.subTitle'], { ns: 'explore' })}
|
||||
</FieldLabel>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t(($) => $['operation.edit'], { ns: 'common' })} ${t(($) => $['appCustomize.subTitle'], { ns: 'explore' })}`}
|
||||
onClick={() => {
|
||||
setShowIconPicker(true)
|
||||
}}
|
||||
className="shrink-0 cursor-pointer rounded-[10px] focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={currentIcon.icon_type === 'link' ? 'image' : currentIcon.icon_type}
|
||||
icon={currentIcon.icon ?? undefined}
|
||||
background={currentIcon.icon_background}
|
||||
imageUrl={
|
||||
currentIcon.icon_type === 'link' ? currentIcon.icon : currentIcon.icon_url
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-x-2 gap-y-1">
|
||||
<IconPickerDialog
|
||||
value={pickerValue}
|
||||
onConfirm={setSelectedIcon}
|
||||
size="large"
|
||||
className="col-start-1 row-start-2"
|
||||
aria-label={`${t(($) => $['operation.edit'], { ns: 'common' })} ${t(($) => $['appCustomize.subTitle'], { ns: 'explore' })}`}
|
||||
/>
|
||||
<Field name="name" className="contents">
|
||||
<FieldLabel className="col-span-2 row-start-1 system-md-medium">
|
||||
{t(($) => $['appCustomize.subTitle'], { ns: 'explore' })}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-10"
|
||||
className="col-start-2 row-start-2 h-10"
|
||||
placeholder={t(($) => $['placeholder.input'], { ns: 'common' }) || ''}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Field>
|
||||
</div>
|
||||
{isAppsFull && <AppsFull className="mt-4" loc="app-duplicate-create" />}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse">
|
||||
@@ -162,19 +151,13 @@ const DuplicateAppModal = ({
|
||||
>
|
||||
{t(($) => $.duplicate, { ns: 'app' })}
|
||||
</Button>
|
||||
<Button type="button" className="w-24" onClick={onHide}>
|
||||
<Button className="w-24" onClick={onHide}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<IconPickerDialog
|
||||
open={showIconPicker}
|
||||
defaultValue={pickerDefaultValue}
|
||||
onOpenChange={setShowIconPicker}
|
||||
onConfirm={setSelectedIcon}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import type { EnvironmentVariableItemResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
@@ -118,7 +118,7 @@ export const AppExportConfirmContent = ({
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton disabled={isExporting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -136,7 +136,7 @@ export const AppExportConfirmContent = ({
|
||||
: t(($) => $.exportApp, { ns: 'app' })}
|
||||
</span>
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import ConversationList from '../list'
|
||||
|
||||
const mockFetchChatMessages = vi.fn()
|
||||
const mockUpdateLogMessageFeedbacks = vi.fn()
|
||||
const mockFeedbackResult = vi.fn()
|
||||
const mockUpdateLogMessageAnnotations = vi.fn()
|
||||
const mockOnRefresh = vi.fn()
|
||||
const mockCompletionRefetch = vi.fn()
|
||||
@@ -187,7 +188,12 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const result = await onFeedback('message-1', { rating: 'like', content: 'nice' })
|
||||
mockFeedbackResult(result)
|
||||
}}
|
||||
>
|
||||
chat-feedback
|
||||
</button>
|
||||
<button
|
||||
@@ -426,81 +432,88 @@ describe('ConversationList', () => {
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
|
||||
it('should render chat conversation details and submit feedback from the chat panel', async () => {
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
user_input_form: [
|
||||
{
|
||||
query: {
|
||||
variable: 'query',
|
||||
},
|
||||
it.each([true, false])(
|
||||
'returns the actual admin feedback result (success: %s)',
|
||||
async (succeeded) => {
|
||||
mockFeedbackResult.mockClear()
|
||||
if (!succeeded)
|
||||
mockUpdateLogMessageFeedbacks.mockRejectedValueOnce(new Error('Feedback failed'))
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
],
|
||||
},
|
||||
message: {
|
||||
inputs: {
|
||||
query: 'Latest question',
|
||||
user_input_form: [
|
||||
{
|
||||
query: {
|
||||
variable: 'query',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
answer: 'Assistant reply',
|
||||
query: 'Latest question',
|
||||
created_at: 1710000000,
|
||||
message: {
|
||||
inputs: {
|
||||
query: 'Latest question',
|
||||
},
|
||||
feedbacks: [],
|
||||
message: [],
|
||||
message_files: [],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
})
|
||||
|
||||
renderConversationList({
|
||||
searchParams: '?page=2&conversation_id=conversation-1',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/chat-messages',
|
||||
params: {
|
||||
conversation_id: 'conversation-1',
|
||||
limit: 10,
|
||||
},
|
||||
}
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
answer: 'Assistant reply',
|
||||
query: 'Latest question',
|
||||
created_at: 1710000000,
|
||||
inputs: {
|
||||
query: 'Latest question',
|
||||
},
|
||||
feedbacks: [],
|
||||
message: [],
|
||||
message_files: [],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
|
||||
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
|
||||
|
||||
fireEvent.click(screen.getByText('chat-feedback'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/feedbacks',
|
||||
body: {
|
||||
message_id: 'message-1',
|
||||
rating: 'like',
|
||||
content: 'nice',
|
||||
},
|
||||
renderConversationList({
|
||||
searchParams: '?page=2&conversation_id=conversation-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/chat-messages',
|
||||
params: {
|
||||
conversation_id: 'conversation-1',
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
|
||||
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
|
||||
|
||||
fireEvent.click(screen.getByText('chat-feedback'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateLogMessageFeedbacks).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/feedbacks',
|
||||
body: {
|
||||
message_id: 'message-1',
|
||||
rating: 'like',
|
||||
content: 'nice',
|
||||
},
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(mockFeedbackResult).toHaveBeenCalledWith(succeeded))
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['chatbot', AppModeEnum.CHAT, 'false'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { AppMode } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import { buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,23 +9,23 @@ import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
type IShareLinkProps = {
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
type CustomizeDialogProps = {
|
||||
disabled?: boolean
|
||||
triggerLabel?: string
|
||||
api_base_url: string
|
||||
appId: string
|
||||
mode?: AppMode
|
||||
sourceCodeRepository?: 'webapp-conversation' | 'webapp-text-generator'
|
||||
}
|
||||
|
||||
const StepNum: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
const StepNum = ({ children }: { children: ReactNode }) => (
|
||||
<div className="mr-3 flex size-7 shrink-0 items-center justify-center rounded-2xl bg-util-colors-blue-blue-50 text-text-accent">
|
||||
{children}
|
||||
</div>
|
||||
@@ -52,14 +52,14 @@ const GithubIcon = ({ className }: { className: string }) => {
|
||||
|
||||
const prefixCustomize = 'overview.appInfo.customize'
|
||||
|
||||
const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
isShow,
|
||||
onClose,
|
||||
export function CustomizeDialog({
|
||||
disabled,
|
||||
triggerLabel,
|
||||
appId,
|
||||
api_base_url,
|
||||
mode,
|
||||
sourceCodeRepository,
|
||||
}) => {
|
||||
}: CustomizeDialogProps) {
|
||||
const { t } = useTranslation(['appOverview', 'common'])
|
||||
const docLink = useDocLink()
|
||||
const isChatApp = mode === AppModeEnum.CHAT || mode === AppModeEnum.ADVANCED_CHAT
|
||||
@@ -68,7 +68,14 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
const apiDocLink = docLink('/api-reference/guides/get-started')
|
||||
|
||||
return (
|
||||
<Dialog open={isShow} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
disabled={disabled}
|
||||
render={<Button variant="secondary" className="flex items-center gap-1 px-3" />}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
|
||||
{triggerLabel ?? t(($) => $['overview.appInfo.customize.entry'], { ns: 'appOverview' })}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-160 flex-col overflow-hidden!">
|
||||
<DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $[`${prefixCustomize}.title`], { ns: 'appOverview' })}
|
||||
@@ -184,5 +191,3 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomizeModal
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { seedAccountProfileQuery } from '@/test/console/account-profile'
|
||||
import { EmbeddedDialog } from '../index'
|
||||
|
||||
const { getProfile } = vi.hoisted(() => ({ getProfile: vi.fn() }))
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
|
||||
vi.mock('@/service/base', () => ({
|
||||
get: getProfile,
|
||||
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 prefix = 'appOverview.overview.appInfo.embedded'
|
||||
const triggerLabel = 'deployments.studio.accessPoint.embedIntoSite'
|
||||
const hiddenInputs = [
|
||||
{
|
||||
variable: 'secret',
|
||||
label: 'Secret',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
required: true,
|
||||
default: 'initial value',
|
||||
},
|
||||
]
|
||||
|
||||
function createClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } })
|
||||
}
|
||||
|
||||
function dialogView(queryClient: QueryClient, props: ComponentProps<typeof EmbeddedDialog> = {}) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EmbeddedDialog
|
||||
accessToken="embed-token"
|
||||
appBaseUrl="https://app.example.com"
|
||||
hiddenInputs={hiddenInputs}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('keeps Close focused while account data loads and returns to the keyboard trigger', async () => {
|
||||
const queryClient = createClient()
|
||||
const { profile } = seedAccountProfileQuery(createClient())
|
||||
let resolveProfile!: (response: Response) => void
|
||||
getProfile.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveProfile = resolve
|
||||
}),
|
||||
)
|
||||
const screen = await render(dialogView(queryClient))
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await userEvent.tab()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
const dialog = screen.getByRole('dialog', { name: `${prefix}.title` })
|
||||
const close = dialog.getByRole('button', { name: 'common.operation.close' })
|
||||
await expect.element(close).toHaveFocus()
|
||||
const initialClose = close.element()
|
||||
await expect.poll(() => getProfile.mock.calls.length).toBe(1)
|
||||
await expect
|
||||
.element(dialog.getByRole('tab', { name: `${prefix}.iframe` }))
|
||||
.not.toBeInTheDocument()
|
||||
|
||||
resolveProfile(
|
||||
new Response(JSON.stringify(profile), {
|
||||
headers: { 'content-type': 'application/json', 'x-env': 'PRODUCTION' },
|
||||
}),
|
||||
)
|
||||
await expect.element(dialog.getByRole('tab', { name: `${prefix}.iframe` })).toBeVisible()
|
||||
expect(close.element()).toBe(initialClose)
|
||||
await expect.element(close).toHaveFocus()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('preserves the hidden input, selected tab and copied preview through exit, then starts fresh', async () => {
|
||||
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
|
||||
const queryClient = createClient()
|
||||
seedAccountProfileQuery(queryClient)
|
||||
const screen = await render(dialogView(queryClient))
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: `${prefix}.title` })
|
||||
const disclosure = dialog.getByRole('button', {
|
||||
name: new RegExp(`${prefix}.hiddenInputs.title`),
|
||||
})
|
||||
await expect.element(disclosure).toHaveAttribute('aria-expanded', 'false')
|
||||
await disclosure.click()
|
||||
const input = dialog.getByRole('textbox', { name: 'Secret' })
|
||||
await input.fill('draft secret')
|
||||
const scriptsTab = dialog.getByRole('tab', { name: `${prefix}.scripts` })
|
||||
await scriptsTab.click()
|
||||
const panel = dialog.getByRole('tabpanel', { name: `${prefix}.scripts` })
|
||||
await expect.poll(() => panel.element().textContent).toContain('draft secret')
|
||||
await panel.getByRole('button', { name: `${prefix}.copy`, exact: true }).click()
|
||||
const copied = panel.getByRole('button', { name: `${prefix}.copied`, exact: true })
|
||||
await expect.element(copied).toBeVisible()
|
||||
expect(copy).toHaveBeenLastCalledWith(expect.stringContaining('draft secret'))
|
||||
|
||||
const popup = dialog.element()
|
||||
const draftInput = input.element() as HTMLInputElement
|
||||
const activeTab = scriptsTab.element()
|
||||
const preview = panel.element()
|
||||
const copyButton = copied.element()
|
||||
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
|
||||
const exitFrame = new Promise<{
|
||||
draft: string
|
||||
selected: string | null
|
||||
preview: string | null
|
||||
copied: string | null
|
||||
opacity: number
|
||||
}>((resolve) => {
|
||||
const onTransition = (event: Event) => {
|
||||
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
|
||||
popup.removeEventListener('transitionrun', onTransition)
|
||||
resolve({
|
||||
draft: draftInput.value,
|
||||
selected: activeTab.getAttribute('aria-selected'),
|
||||
preview: preview.textContent,
|
||||
copied: copyButton.getAttribute('aria-label'),
|
||||
opacity: Number(getComputedStyle(popup).opacity),
|
||||
})
|
||||
}
|
||||
popup.addEventListener('transitionrun', onTransition)
|
||||
})
|
||||
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
const closing = await exitFrame
|
||||
expect(closing.opacity).toBeGreaterThan(0)
|
||||
expect(closing.draft).toBe('draft secret')
|
||||
expect(closing.selected).toBe('true')
|
||||
expect(closing.preview).toContain('draft secret')
|
||||
expect(closing.copied).toBe(`${prefix}.copied`)
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
|
||||
await userEvent.keyboard(' ')
|
||||
await expect
|
||||
.element(dialog.getByRole('tab', { name: `${prefix}.iframe` }))
|
||||
.toHaveAttribute('aria-selected', 'true')
|
||||
await expect.element(disclosure).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: `${prefix}.copy`, exact: true }))
|
||||
.toBeVisible()
|
||||
await disclosure.click()
|
||||
await expect.element(input).toHaveValue('initial value')
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('ends the active session when disabled or missing its token and keeps a disabled entry', async () => {
|
||||
const queryClient = createClient()
|
||||
seedAccountProfileQuery(queryClient)
|
||||
const screen = await render(dialogView(queryClient))
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
const dialog = screen.getByRole('dialog', { name: `${prefix}.title` })
|
||||
for (const unavailable of [{ disabled: true }, { accessToken: undefined }]) {
|
||||
await trigger.click()
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await screen.rerender(dialogView(queryClient, unavailable))
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toBeDisabled()
|
||||
await screen.rerender(dialogView(queryClient))
|
||||
await expect.element(trigger).not.toBeDisabled()
|
||||
}
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { act } from 'react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import Embedded from '../index'
|
||||
import { EmbeddedDialog } from '../index'
|
||||
|
||||
vi.mock('../style.module.css', () => ({
|
||||
default: {
|
||||
@@ -33,17 +33,14 @@ const siteInfo: SiteInfo = {
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
isShow: true,
|
||||
siteInfo,
|
||||
onClose: vi.fn(),
|
||||
appBaseUrl: 'https://app.example.com',
|
||||
accessToken: 'token',
|
||||
className: 'custom-modal',
|
||||
}
|
||||
|
||||
const getCopyButton = () => screen.getByRole('button', { name: /copy/i })
|
||||
|
||||
describe('Embedded', () => {
|
||||
describe('EmbeddedDialog', () => {
|
||||
beforeAll(() => {
|
||||
class MockCompressionStream {
|
||||
readable: ReadableStream<Uint8Array>
|
||||
@@ -74,8 +71,9 @@ describe('Embedded', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -97,8 +95,9 @@ describe('Embedded', () => {
|
||||
it('links each embed method tab to a panel and supports arrow key selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
const iframe = screen.getByRole('tab', {
|
||||
name: 'appOverview.overview.appInfo.embedded.iframe',
|
||||
@@ -135,8 +134,9 @@ describe('Embedded', () => {
|
||||
it('opens chrome plugin store link when chrome option selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
@@ -157,23 +157,46 @@ describe('Embedded', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onClose when the close button is clicked', async () => {
|
||||
it('starts a fresh tab session after closing and reopening', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} onClose={onClose} />)
|
||||
})
|
||||
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
const trigger = screen.getByRole('button', { name: /embedIntoSite/ })
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('tab', { name: /embedded.scripts/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('tab', { name: /embedded.iframe/ })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['disabled', 'missing-token'] as const)(
|
||||
'keeps a visible unavailable trigger for %s and ends its session',
|
||||
async (reason) => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<EmbeddedDialog {...baseProps} />)
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
rerender(
|
||||
<EmbeddedDialog
|
||||
{...baseProps}
|
||||
disabled={reason === 'disabled'}
|
||||
accessToken={reason === 'missing-token' ? undefined : baseProps.accessToken}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /embedIntoSite/ })).toBeDisabled()
|
||||
rerender(<EmbeddedDialog {...baseProps} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Embedded
|
||||
<EmbeddedDialog
|
||||
{...baseProps}
|
||||
hiddenInputs={[
|
||||
{
|
||||
@@ -188,6 +211,8 @@ describe('Embedded', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
@@ -231,8 +256,9 @@ describe('Embedded', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
@@ -258,8 +284,9 @@ describe('Embedded', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
await act(async () => {
|
||||
render(<Embedded {...baseProps} />)
|
||||
render(<EmbeddedDialog {...baseProps} />)
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /embedIntoSite/ }))
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
|
||||
@@ -5,8 +5,16 @@ import type {
|
||||
WorkflowHiddenStartVariable,
|
||||
WorkflowLaunchInputValue,
|
||||
} from '../app-card-utils'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
@@ -29,15 +37,14 @@ import {
|
||||
import WorkflowHiddenInputFields from '../workflow-hidden-input-fields'
|
||||
import style from './style.module.css'
|
||||
|
||||
type Props = Readonly<{
|
||||
type EmbeddedDialogProps = Readonly<{
|
||||
siteInfo?: Partial<Pick<AppDetailSiteResponse, 'chat_color_theme' | 'chat_color_theme_inverted'>>
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
disabled?: boolean
|
||||
triggerLabel?: string
|
||||
accessToken?: string
|
||||
appBaseUrl?: string
|
||||
webAppRoute?: EmbeddedWebAppRoute
|
||||
hiddenInputs?: WorkflowHiddenStartVariable[]
|
||||
className?: string
|
||||
}>
|
||||
|
||||
const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const
|
||||
@@ -114,8 +121,8 @@ const EmbeddedContent = ({
|
||||
accessToken,
|
||||
webAppRoute = 'chatbot',
|
||||
hiddenInputs,
|
||||
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> &
|
||||
Pick<Props, 'siteInfo' | 'webAppRoute' | 'hiddenInputs'>) => {
|
||||
}: Required<Pick<EmbeddedDialogProps, 'accessToken' | 'appBaseUrl'>> &
|
||||
Pick<EmbeddedDialogProps, 'siteInfo' | 'webAppRoute' | 'hiddenInputs'>) => {
|
||||
const { t } = useTranslation(['appOverview'])
|
||||
const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>(
|
||||
() => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported),
|
||||
@@ -213,14 +220,15 @@ const EmbeddedContent = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
<DialogDescription className="mt-8 mb-4 system-sm-medium text-text-primary">
|
||||
{t(($) => $[`${prefixEmbedded}.explanation`], { ns: 'appOverview' })}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
{supportedHiddenInputs.length > 0 && (
|
||||
<div className="mb-6 rounded-xl border-[0.5px] border-components-panel-border bg-background-section">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||||
aria-expanded={!hiddenInputsCollapsed}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-input-border-active"
|
||||
onClick={() => setHiddenInputsCollapsed((prev) => !prev)}
|
||||
>
|
||||
<div>
|
||||
@@ -368,29 +376,30 @@ const EmbeddedContent = ({
|
||||
)
|
||||
}
|
||||
|
||||
const Embedded = ({
|
||||
export function EmbeddedDialog({
|
||||
siteInfo,
|
||||
isShow,
|
||||
onClose,
|
||||
disabled,
|
||||
triggerLabel,
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
webAppRoute = 'chatbot',
|
||||
hiddenInputs,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation(['appOverview', 'common'])
|
||||
}: EmbeddedDialogProps) {
|
||||
const { t } = useTranslation(['appOverview', 'common', 'deployments'])
|
||||
const unavailable = disabled || !accessToken
|
||||
const trigger = (
|
||||
<Button variant="secondary" disabled={unavailable} className="px-3">
|
||||
<span aria-hidden className="i-ri-window-line size-4" />
|
||||
{triggerLabel ?? t(($) => $['studio.accessPoint.embedIntoSite'], { ns: 'deployments' })}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (unavailable) return trigger
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isShow}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn('flex max-h-[calc(100dvh-2rem)] w-160 flex-col overflow-hidden!', className)}
|
||||
>
|
||||
<Dialog>
|
||||
<DialogTrigger render={trigger} />
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-160 flex-col overflow-hidden!">
|
||||
<DialogTitle className="shrink-0 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $[`${prefixEmbedded}.title`], { ns: 'appOverview' })}
|
||||
</DialogTitle>
|
||||
@@ -406,20 +415,22 @@ const Embedded = ({
|
||||
}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{isShow && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="py-8 text-text-tertiary">{t(($) => $.loading, { ns: 'common' })}</div>
|
||||
}
|
||||
>
|
||||
<EmbeddedContent
|
||||
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${webAppRoute}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
key={`${appBaseUrl ?? ''}:${accessToken}:${webAppRoute}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
siteInfo={siteInfo}
|
||||
appBaseUrl={appBaseUrl ?? ''}
|
||||
accessToken={accessToken ?? ''}
|
||||
accessToken={accessToken}
|
||||
webAppRoute={webAppRoute}
|
||||
hiddenInputs={hiddenInputs}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default Embedded
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AppSiteUpdatePayload } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { SettingsAppInfo } from '../index'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
} from '@/test/console/query-data'
|
||||
import { createAppSiteFixture } from '@/test/fixtures/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SettingsModal from '../index'
|
||||
import { SettingsDialog } from '../index'
|
||||
|
||||
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
|
||||
|
||||
@@ -64,8 +65,7 @@ vi.mock('@/app/notifications', () => ({
|
||||
promise: toastMocks.promise,
|
||||
}),
|
||||
}))
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
const mockOnSave = vi.fn<(params: AppSiteUpdatePayload) => Promise<boolean>>()
|
||||
|
||||
vi.mock('@/context/i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
|
||||
@@ -97,17 +97,16 @@ const mockAppInfo = {
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
} satisfies SettingsAppInfo
|
||||
|
||||
const renderSettingsModal = (appInfo: SettingsAppInfo = mockAppInfo, canDeploy = false) =>
|
||||
render(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={canDeploy}
|
||||
isShow
|
||||
appInfo={appInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
const triggerName = 'navigation.settings.settings'
|
||||
const dialogName = 'appOverview.overview.appInfo.settings.title'
|
||||
|
||||
const renderSettingsDialog = async (appInfo: SettingsAppInfo = mockAppInfo, canDeploy = false) => {
|
||||
const view = render(
|
||||
<SettingsDialog isChat canDeploy={canDeploy} appInfo={appInfo} onSave={mockOnSave} />,
|
||||
)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: triggerName }))
|
||||
return view
|
||||
}
|
||||
|
||||
const inputPlaceholderName = 'appOverview.overview.appInfo.settings.more.inputPlaceholder'
|
||||
|
||||
@@ -120,11 +119,10 @@ function render(...args: Parameters<typeof renderWithData>) {
|
||||
return { ...result, rerender: (ui: ReactElement) => result.rerender(wrap(ui)) }
|
||||
}
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
describe('SettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
toastMocks.call.mockClear()
|
||||
mockOnClose.mockClear()
|
||||
mockOnSave.mockClear()
|
||||
mockOnSave.mockReset().mockResolvedValue(true)
|
||||
onPricingUrlUpdate.mockClear()
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = true
|
||||
@@ -135,7 +133,11 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
|
||||
it('edits a site with nullable optional fields as empty form values', async () => {
|
||||
renderSettingsModal({ id: 'app-null-fields', mode: 'chat', site: createAppSiteFixture() })
|
||||
await renderSettingsDialog({
|
||||
id: 'app-null-fields',
|
||||
mode: 'chat',
|
||||
site: createAppSiteFixture(),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('')
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
@@ -154,11 +156,13 @@ describe('SettingsModal', () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(mockOnClose).toHaveBeenCalledOnce()
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the modal with all settings exposed by default', async () => {
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
|
||||
@@ -178,8 +182,8 @@ describe('SettingsModal', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should explain that Web app settings apply to every environment when ACL allows deploy', () => {
|
||||
renderSettingsModal(mockAppInfo, true)
|
||||
it('should explain that Web app settings apply to every environment when ACL allows deploy', async () => {
|
||||
await renderSettingsDialog(mockAppInfo, true)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'appOverview.overview.appInfo.settings.multiEnvironmentNotice',
|
||||
@@ -188,7 +192,7 @@ describe('SettingsModal', () => {
|
||||
|
||||
it('names the Inverted switch from its visible label and exposes its state', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
|
||||
const invertedSwitch = screen.getByRole('switch', {
|
||||
name: 'appOverview.overview.appInfo.settings.chatColorThemeInverted',
|
||||
@@ -201,7 +205,7 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
|
||||
it('should notify the user when the name is empty', async () => {
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
const nameInput = screen.getByPlaceholderText('app.appNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
@@ -215,7 +219,7 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
|
||||
it('should validate the theme color and show an error when the hex is invalid', async () => {
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
const colorInput = screen.getByPlaceholderText('E.g #A020F0')
|
||||
fireEvent.change(colorInput, { target: { value: 'not-a-hex' } })
|
||||
|
||||
@@ -231,7 +235,7 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
|
||||
it('should validate the privacy policy URL', async () => {
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
const privacyInput = screen.getByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
|
||||
)
|
||||
@@ -250,8 +254,8 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
|
||||
it('should save valid settings and close the modal', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
renderSettingsModal()
|
||||
mockOnSave.mockResolvedValueOnce(true)
|
||||
await renderSettingsDialog()
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
@@ -275,98 +279,48 @@ describe('SettingsModal', () => {
|
||||
use_icon_as_answer_icon: mockAppInfo.site.use_icon_as_answer_icon,
|
||||
}),
|
||||
)
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should not render a show-more trigger', () => {
|
||||
renderSettingsModal()
|
||||
|
||||
expect(
|
||||
screen.queryByText('appOverview.overview.appInfo.settings.more.entry'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset local form state when the controlled dialog reopens', () => {
|
||||
const { rerender } = render(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={true}
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={false}
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
rerender(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={true}
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByText('appOverview.overview.appInfo.settings.more.entry'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByPlaceholderText(
|
||||
'appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset the input placeholder when app info changes while open', () => {
|
||||
const { rerender } = render(
|
||||
<SettingsModal
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={true}
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
it('discards cancelled edits and initializes the next session from committed settings', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderSettingsDialog()
|
||||
const placeholder = screen.getByRole('textbox', { name: inputPlaceholderName })
|
||||
await user.clear(placeholder)
|
||||
await user.type(placeholder, 'Cancelled prompt')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(mockOnSave).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: triggerName }))
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue(
|
||||
'Ask me anything',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps drafts for equivalent source data and resets them when committed settings change', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = await renderSettingsDialog()
|
||||
await user.clear(screen.getByRole('textbox', { name: inputPlaceholderName }))
|
||||
await user.type(screen.getByRole('textbox', { name: inputPlaceholderName }), 'Current draft')
|
||||
rerender(
|
||||
<SettingsModal
|
||||
<SettingsDialog
|
||||
isChat
|
||||
appInfo={{ ...mockAppInfo, site: { ...mockAppInfo.site } }}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toHaveValue('Current draft')
|
||||
rerender(
|
||||
<SettingsDialog
|
||||
isChat
|
||||
canDeploy={false}
|
||||
isShow={true}
|
||||
appInfo={{
|
||||
...mockAppInfo,
|
||||
site: {
|
||||
...mockAppInfo.site,
|
||||
input_placeholder: 'Updated prompt',
|
||||
},
|
||||
site: { ...mockAppInfo.site, input_placeholder: 'Updated prompt' },
|
||||
}}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
@@ -375,12 +329,138 @@ describe('SettingsModal', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['false', 'rejection'] as const)(
|
||||
'retains the draft after a %s save result and permits retry',
|
||||
async (failure) => {
|
||||
const user = userEvent.setup()
|
||||
if (failure === 'false') mockOnSave.mockResolvedValueOnce(false)
|
||||
else mockOnSave.mockRejectedValueOnce(new Error('Unexpected failure'))
|
||||
await renderSettingsDialog()
|
||||
const name = screen.getByPlaceholderText('app.appNamePlaceholder')
|
||||
await user.clear(name)
|
||||
await user.type(name, 'Unsaved title')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() => expect(name).not.toHaveAttribute('readonly'))
|
||||
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
|
||||
expect(name).toHaveValue('Unsaved title')
|
||||
expect(toastMocks.call).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(mockOnSave).toHaveBeenCalledTimes(2)
|
||||
expect(mockOnSave).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ title: 'Unsaved title' }),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps the pending lock when committed metadata replaces the form draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finishSave!: (saved: boolean) => void
|
||||
mockOnSave.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishSave = resolve
|
||||
}),
|
||||
)
|
||||
const { rerender } = await renderSettingsDialog()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
rerender(
|
||||
<SettingsDialog
|
||||
isChat
|
||||
appInfo={{ ...mockAppInfo, site: { ...mockAppInfo.site, title: 'Refreshed title' } }}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
const name = screen.getByPlaceholderText('app.appNamePlaceholder')
|
||||
expect(name).toHaveValue('Refreshed title')
|
||||
expect(name).toHaveAttribute('readonly')
|
||||
await user.type(name, 'ignored')
|
||||
expect(name).toHaveValue('Refreshed title')
|
||||
expect(screen.getByRole('button', { name: 'app.iconPicker.title' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeDisabled()
|
||||
const invertedSwitch = screen.getByRole('switch', {
|
||||
name: 'appOverview.overview.appInfo.settings.chatColorThemeInverted',
|
||||
})
|
||||
await user.click(invertedSwitch)
|
||||
expect(invertedSwitch).toBeChecked()
|
||||
const language = screen.getByRole('combobox', {
|
||||
name: 'appOverview.overview.appInfo.settings.language',
|
||||
})
|
||||
const currentLanguage = language.textContent
|
||||
await user.click(language)
|
||||
await user.click(screen.getByRole('option', { name: '简体中文' }))
|
||||
expect(language).toHaveTextContent(currentLanguage!)
|
||||
await user.keyboard('{Escape}')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await user.keyboard('{Escape}')
|
||||
expect(mockOnSave).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
finishSave(true)
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['missing-data', 'disabled'] as const)(
|
||||
'keeps an unavailable %s entry visible until ready',
|
||||
async (unavailable) => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(
|
||||
<SettingsDialog
|
||||
isChat
|
||||
appInfo={unavailable === 'missing-data' ? undefined : mockAppInfo}
|
||||
disabled={unavailable === 'disabled'}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: triggerName })
|
||||
expect(trigger).toBeDisabled()
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument()
|
||||
rerender(<SettingsDialog isChat appInfo={mockAppInfo} onSave={mockOnSave} />)
|
||||
await user.click(screen.getByRole('button', { name: triggerName }))
|
||||
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps an unfinished save locked when permission disappears and returns', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finishSave!: (saved: boolean) => void
|
||||
mockOnSave.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishSave = resolve
|
||||
}),
|
||||
)
|
||||
const { rerender } = await renderSettingsDialog()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
rerender(<SettingsDialog isChat appInfo={mockAppInfo} disabled onSave={mockOnSave} />)
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument()
|
||||
rerender(<SettingsDialog isChat appInfo={mockAppInfo} onSave={mockOnSave} />)
|
||||
const trigger = screen.getByRole('button', { name: triggerName })
|
||||
expect(trigger).toBeDisabled()
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('dialog', { name: dialogName })).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
finishSave(true)
|
||||
})
|
||||
await waitFor(() => expect(trigger).toBeEnabled())
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('dialog', { name: dialogName })).toBeInTheDocument()
|
||||
expect(mockOnSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should preserve restricted settings when saving other Cloud settings', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
mockOnSave.mockResolvedValueOnce(true)
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = false
|
||||
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
|
||||
const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName })
|
||||
expect(inputPlaceholder).toBeDisabled()
|
||||
@@ -404,11 +484,11 @@ describe('SettingsModal', () => {
|
||||
})
|
||||
|
||||
it('should keep the input placeholder editable when billing is disabled', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
mockOnSave.mockResolvedValueOnce(true)
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
copyrightEnabled = false
|
||||
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
const inputPlaceholder = screen.getByRole('textbox', { name: inputPlaceholderName })
|
||||
fireEvent.change(inputPlaceholder, { target: { value: 'Self-hosted prompt' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
@@ -429,7 +509,7 @@ describe('SettingsModal', () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = false
|
||||
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
fireEvent.click((await screen.findAllByText('billing.upgradeBtn.encourageShort'))[0]!)
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -441,14 +521,14 @@ describe('SettingsModal', () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
copyrightEnabled = true
|
||||
|
||||
renderSettingsModal()
|
||||
await renderSettingsDialog()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve image icons and apply textarea or switch changes when saving image-based settings', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
mockOnSave.mockResolvedValueOnce(true)
|
||||
const imageAppInfo = {
|
||||
...mockAppInfo,
|
||||
site: {
|
||||
@@ -460,7 +540,7 @@ describe('SettingsModal', () => {
|
||||
},
|
||||
} satisfies SettingsAppInfo
|
||||
|
||||
renderSettingsModal(imageAppInfo)
|
||||
await renderSettingsDialog(imageAppInfo)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('A description'), {
|
||||
target: { value: 'Updated description' },
|
||||
@@ -511,12 +591,13 @@ it('saves unrelated settings while entitlements are pending without clearing pro
|
||||
...consoleQuery.features.get.queryOptions(),
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(<SettingsModal isChat isShow appInfo={mockAppInfo} onClose={vi.fn()} onSave={onSave} />, {
|
||||
const onSave = vi.fn().mockResolvedValue(true)
|
||||
render(<SettingsDialog isChat appInfo={mockAppInfo} onSave={onSave} />, {
|
||||
queryClient,
|
||||
features: undefined,
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: triggerName }))
|
||||
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toBeDisabled()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { useAccessPointActions } from '@/app/components/app/access-point/shared/use-access-point-actions'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { emojiCatalogOptions } from '@/app/components/base/icon-picker/emoji-data'
|
||||
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
|
||||
import { QueryClientTestProvider } from '@/test/console/query-provider'
|
||||
import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app'
|
||||
import { SettingsDialog } from '../index'
|
||||
|
||||
const { saveSite, fetchApp, notify } = vi.hoisted(() => ({
|
||||
saveSite: vi.fn(),
|
||||
fetchApp: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
return {
|
||||
...actual,
|
||||
consoleClient: {
|
||||
...actual.consoleClient,
|
||||
apps: {
|
||||
...actual.consoleClient.apps,
|
||||
byAppId: {
|
||||
...actual.consoleClient.apps.byAppId,
|
||||
get: fetchApp,
|
||||
site: { ...actual.consoleClient.apps.byAppId.site, post: saveSite },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/notifications', () => ({
|
||||
toast: Object.assign(notify, { success: notify, error: notify, warning: notify, info: notify }),
|
||||
}))
|
||||
vi.mock('@/next/navigation', () => ({ useParams: () => ({}) }))
|
||||
|
||||
const settingsTitle = 'appOverview.overview.appInfo.settings.title'
|
||||
const titleLabel = 'appOverview.overview.appInfo.settings.webName'
|
||||
const triggerLabel = 'navigation.settings.settings'
|
||||
|
||||
function createApp(title = 'Original app'): AppDetailWithSite {
|
||||
return createAppDetailFixture({
|
||||
id: 'settings-app',
|
||||
mode: 'advanced-chat',
|
||||
site: createAppSiteFixture({
|
||||
title,
|
||||
description: 'Original description',
|
||||
icon_type: 'emoji',
|
||||
icon: '😀',
|
||||
icon_background: '#FEF3F2',
|
||||
default_language: 'en-US',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function SettingsOwner() {
|
||||
const appInfo = useAppStore((state) => state.appDetail)
|
||||
const { saveSiteConfig } = useAccessPointActions('settings-app', true)
|
||||
return (
|
||||
<SettingsDialog
|
||||
isChat
|
||||
appInfo={
|
||||
appInfo?.site ? { id: appInfo.id, mode: appInfo.mode, site: appInfo.site } : undefined
|
||||
}
|
||||
onSave={saveSiteConfig}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderSettings() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, retry: false } },
|
||||
})
|
||||
seedSystemFeatures(client, { deployment_edition: 'COMMUNITY' })
|
||||
seedFeatures(client, { webapp_copyright_enabled: true })
|
||||
client.setQueryData(emojiCatalogOptions.queryKey, [
|
||||
{
|
||||
id: 'faces',
|
||||
label: 'Faces',
|
||||
items: [{ emoji: '😎', label: 'Cool face', version: 1 }],
|
||||
},
|
||||
])
|
||||
const screen = await render(
|
||||
<QueryClientTestProvider queryClient={client}>
|
||||
<NuqsTestingAdapter>
|
||||
<SettingsOwner />
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientTestProvider>,
|
||||
)
|
||||
return { screen, client }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({ appDetail: createApp() })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
})
|
||||
|
||||
it('retains a failed save draft and closes a successful retry before the background refresh finishes', async () => {
|
||||
let rejectSave!: (error: Error) => void
|
||||
saveSite.mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectSave = reject
|
||||
}),
|
||||
)
|
||||
saveSite.mockResolvedValueOnce({})
|
||||
let finishRefresh!: (value: AppDetailWithSite) => void
|
||||
fetchApp.mockReturnValueOnce(
|
||||
new Promise<AppDetailWithSite>((resolve) => {
|
||||
finishRefresh = resolve
|
||||
}),
|
||||
)
|
||||
const { screen, client } = await renderSettings()
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: settingsTitle })
|
||||
const title = dialog.getByRole('textbox', { name: titleLabel })
|
||||
await title.fill('Saved after retry')
|
||||
const save = dialog.getByRole('button', { name: 'common.operation.save' })
|
||||
await save.click()
|
||||
await expect.element(save).toHaveFocus()
|
||||
await expect.element(save).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(title).toHaveAttribute('readonly')
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: 'common.operation.close' }))
|
||||
.toBeDisabled()
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
.toBeDisabled()
|
||||
await userEvent.keyboard('{Enter}{Escape}')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
expect(saveSite).toHaveBeenCalledTimes(1)
|
||||
expect(fetchApp).not.toHaveBeenCalled()
|
||||
rejectSave(new Error('Save failed'))
|
||||
await expect.element(save).not.toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(title).not.toHaveAttribute('readonly')
|
||||
await expect.element(title).toHaveValue('Saved after retry')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
|
||||
await save.click()
|
||||
await expect.poll(() => fetchApp.mock.calls.length).toBe(1)
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
expect(saveSite).toHaveBeenCalledTimes(2)
|
||||
expect(useAppStore.getState().appDetail?.site?.title).toBe('Original app')
|
||||
const refreshed = createApp('Saved after retry')
|
||||
finishRefresh(refreshed)
|
||||
await expect.poll(() => useAppStore.getState().appDetail).toEqual(refreshed)
|
||||
await trigger.click()
|
||||
await expect.element(title).toHaveValue('Saved after retry')
|
||||
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
expect(saveSite).toHaveBeenLastCalledWith({
|
||||
params: { app_id: 'settings-app' },
|
||||
body: expect.objectContaining({ title: 'Saved after retry' }),
|
||||
})
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('closes after a successful POST even when the background detail refresh fails', async () => {
|
||||
saveSite.mockResolvedValueOnce({})
|
||||
fetchApp.mockRejectedValueOnce(new Error('Refresh failed'))
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { screen, client } = await renderSettings()
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: settingsTitle })
|
||||
await dialog.getByRole('textbox', { name: titleLabel }).fill('Saved remotely')
|
||||
await dialog.getByRole('button', { name: 'common.operation.save' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
await expect.poll(() => fetchApp.mock.calls.length).toBe(1)
|
||||
expect(notify).toHaveBeenCalledExactlyOnceWith('common.actionMsg.modifiedSuccessfully', {
|
||||
type: 'success',
|
||||
})
|
||||
expect(saveSite).toHaveBeenCalledTimes(1)
|
||||
client.clear()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the draft through the exit animation and reopens with the latest owner metadata', async () => {
|
||||
vi.stubGlobal('BASE_UI_ANIMATIONS_DISABLED', false)
|
||||
const { screen, client } = await renderSettings()
|
||||
const trigger = screen.getByRole('button', { name: triggerLabel })
|
||||
await userEvent.tab()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
const dialog = screen.getByRole('dialog', { name: settingsTitle })
|
||||
const title = dialog.getByRole('textbox', { name: titleLabel })
|
||||
await title.fill('Cancelled draft')
|
||||
const popup = dialog.element()
|
||||
const input = title.element() as HTMLInputElement
|
||||
await expect.poll(() => getComputedStyle(popup).opacity).toBe('1')
|
||||
const exitFrame = new Promise<{ value: string; opacity: number }>((resolve) => {
|
||||
const onTransition = (event: Event) => {
|
||||
if (event.target !== popup || (event as TransitionEvent).propertyName !== 'opacity') return
|
||||
popup.removeEventListener('transitionrun', onTransition)
|
||||
resolve({ value: input.value, opacity: Number(getComputedStyle(popup).opacity) })
|
||||
}
|
||||
popup.addEventListener('transitionrun', onTransition)
|
||||
})
|
||||
await dialog.getByRole('button', { name: 'common.operation.cancel' }).click()
|
||||
const closing = await exitFrame
|
||||
expect(closing.value).toBe('Cancelled draft')
|
||||
expect(closing.opacity).toBeGreaterThan(0)
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
useAppStore.setState({ appDetail: createApp('Fresh metadata') })
|
||||
await userEvent.keyboard(' ')
|
||||
await expect.element(title).toHaveValue('Fresh metadata')
|
||||
await title.fill('Another local draft')
|
||||
useAppStore.setState({ appDetail: createApp('Changed while open') })
|
||||
await expect.element(title).toHaveValue('Changed while open')
|
||||
await expect
|
||||
.poll(() => {
|
||||
const close = dialog.getByRole('button', { name: 'common.operation.close' }).element()
|
||||
return document.activeElement === title.element() || document.activeElement === close
|
||||
? 'settings control'
|
||||
: document.activeElement?.tagName
|
||||
})
|
||||
.toBe('settings control')
|
||||
await dialog.getByRole('button', { name: 'common.operation.close' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
expect(saveSite).not.toHaveBeenCalled()
|
||||
client.clear()
|
||||
})
|
||||
|
||||
it('keeps icon search and confirmation inside the nested picker without submitting Settings', async () => {
|
||||
const { screen, client } = await renderSettings()
|
||||
await screen.getByRole('button', { name: triggerLabel }).click()
|
||||
const settings = screen.getByRole('dialog', { name: settingsTitle })
|
||||
const title = settings.getByRole('textbox', { name: titleLabel })
|
||||
await title.fill('Keep this draft')
|
||||
const iconTrigger = settings.getByRole('button', { name: 'app.iconPicker.title' })
|
||||
await iconTrigger.click()
|
||||
const picker = screen.getByRole('dialog', { name: 'app.iconPicker.title' })
|
||||
await picker.getByRole('combobox', { name: 'app.iconPicker.search' }).fill('Cool')
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}')
|
||||
expect(saveSite).not.toHaveBeenCalled()
|
||||
await picker.getByRole('button', { name: 'app.iconPicker.ok' }).click()
|
||||
await expect.element(picker).not.toBeInTheDocument()
|
||||
await expect.element(settings).toBeVisible()
|
||||
await expect.element(iconTrigger).toHaveFocus()
|
||||
await expect.element(title).toHaveValue('Keep this draft')
|
||||
expect(saveSite).not.toHaveBeenCalled()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.element(picker).toBeVisible()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(picker).not.toBeInTheDocument()
|
||||
await expect.element(settings).toBeVisible()
|
||||
await expect.element(iconTrigger).toHaveFocus()
|
||||
expect(saveSite).not.toHaveBeenCalled()
|
||||
client.clear()
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,12 +74,6 @@ vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ onClick }: { onClick: () => void }) => (
|
||||
<button onClick={onClick}>open-icon-picker</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockApp = (overrides: Partial<AppPartial> = {}): AppPartial => ({
|
||||
id: 'app-123',
|
||||
name: 'Demo App',
|
||||
@@ -288,7 +282,7 @@ describe('SwitchAppModal', () => {
|
||||
permission_keys: ['app.acl.view_layout'],
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('open-icon-picker'))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
@@ -318,7 +312,7 @@ describe('SwitchAppModal', () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByText('open-icon-picker'))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.title' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -19,7 +19,6 @@ import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { IconPickerDialog } from '@/app/components/base/icon-picker'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { toast } from '@/app/notifications'
|
||||
@@ -60,7 +59,6 @@ const SwitchAppModal = ({ show, sourceApp, onClose }: SwitchAppModalProps) => {
|
||||
appQuota.limit > 0 &&
|
||||
appQuota.size >= appQuota.limit
|
||||
|
||||
const [showIconPicker, setShowIconPicker] = useState(false)
|
||||
const appIconType = zIconType.safeParse(sourceApp.icon_type).data
|
||||
const [appIcon, setAppIcon] = useState(
|
||||
appIconType === 'image'
|
||||
@@ -165,21 +163,7 @@ const SwitchAppModal = ({ show, sourceApp, onClose }: SwitchAppModalProps) => {
|
||||
{t(($) => $.switchLabel, { ns: 'app' })}
|
||||
</label>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['iconPicker.title'], { ns: 'app' })}
|
||||
className="shrink-0 cursor-pointer rounded-[10px] focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={() => setShowIconPicker(true)}
|
||||
>
|
||||
<AppIcon
|
||||
decorative
|
||||
size="large"
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
|
||||
background={appIcon.type === 'image' ? undefined : appIcon.background}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
/>
|
||||
</button>
|
||||
<IconPickerDialog value={appIcon} onConfirm={setAppIcon} size="large" />
|
||||
<Input
|
||||
id={nameInputId}
|
||||
value={name}
|
||||
@@ -188,14 +172,6 @@ const SwitchAppModal = ({ show, sourceApp, onClose }: SwitchAppModalProps) => {
|
||||
className="h-10 grow"
|
||||
/>
|
||||
</div>
|
||||
<IconPickerDialog
|
||||
open={showIconPicker}
|
||||
defaultValue={appIcon}
|
||||
onOpenChange={setShowIconPicker}
|
||||
onConfirm={(payload) => {
|
||||
setAppIcon(payload)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isAppsFull && <AppsFull loc="app-switch" />}
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
@@ -241,14 +217,14 @@ const SwitchAppModal = ({ show, sourceApp, onClose }: SwitchAppModalProps) => {
|
||||
{t(($) => $.deleteAppConfirmContent, { ns: 'app' })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={() => setShowConfirmDelete(false)}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
|
||||
@@ -10,11 +10,11 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
|
||||
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -725,7 +725,7 @@ export function AppCardInteractions({
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton type="button" disabled={isDeleting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
@@ -736,7 +736,7 @@ export function AppCardInteractions({
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { RenameConversationDialog } from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { useChatWithHistoryContext } from './context'
|
||||
import MobileOperationDropdown from './header/mobile-operation-dropdown'
|
||||
import Operation from './header/operation'
|
||||
@@ -195,25 +195,25 @@ const HeaderInMobile = () => {
|
||||
{t(($) => $['chat.deleteConversation.content'], { ns: 'share' }) || ''}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
<RenameConversationDialog
|
||||
open={!!showRename}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCancelRename()
|
||||
}}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { Ref } from 'react'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -17,7 +17,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { RenameConversationDialog } from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
import Operation from './operation'
|
||||
|
||||
@@ -183,25 +183,25 @@ const Header = ({ toggleButtonRef }: { toggleButtonRef?: Ref<HTMLButtonElement>
|
||||
{t(($) => $['chat.deleteConversation.content'], { ns: 'share' }) || ''}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
<RenameConversationDialog
|
||||
open={!!showRename}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCancelRename()
|
||||
}}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import { useChatWithHistoryContext } from '../../context'
|
||||
import Sidebar from '../index'
|
||||
import RenameModal from '../rename-modal'
|
||||
import { RenameConversationDialog } from '../rename-modal'
|
||||
|
||||
let mockBranding: { enabled: boolean; workspace_logo: string } = {
|
||||
enabled: false,
|
||||
@@ -523,7 +523,7 @@ describe('Sidebar Index', () => {
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Name')
|
||||
|
||||
// The RenameModal has a save button
|
||||
// The RenameConversationDialog has a save button
|
||||
const saveButton = screen.getByText('common.operation.save')
|
||||
await user.click(saveButton)
|
||||
|
||||
@@ -872,14 +872,14 @@ describe('Sidebar Index', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('RenameModal', () => {
|
||||
describe('RenameConversationDialog', () => {
|
||||
it('should render title when modal is shown', () => {
|
||||
render(
|
||||
<RenameModal
|
||||
isShow
|
||||
<RenameConversationDialog
|
||||
open
|
||||
saveLoading={false}
|
||||
name="Conversation"
|
||||
onClose={vi.fn()}
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
@@ -891,11 +891,11 @@ describe('RenameModal', () => {
|
||||
const useTranslationSpy = mockUseTranslationWithEmptyKeys(['chat.conversationNamePlaceholder'])
|
||||
try {
|
||||
render(
|
||||
<RenameModal
|
||||
isShow
|
||||
<RenameConversationDialog
|
||||
open
|
||||
saveLoading={false}
|
||||
name="Conversation"
|
||||
onClose={vi.fn()}
|
||||
onOpenChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as ReactI18next from 'react-i18next'
|
||||
import { expectLoadingButton } from '@/test/button'
|
||||
import { withSelectorKey } from '@/test/i18n-mock'
|
||||
import RenameModal from '../rename-modal'
|
||||
import { RenameConversationDialog } from '../rename-modal'
|
||||
|
||||
describe('RenameModal', () => {
|
||||
describe('RenameConversationDialog', () => {
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
open: true,
|
||||
saveLoading: false,
|
||||
name: 'Original Name',
|
||||
onClose: vi.fn(),
|
||||
onOpenChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('RenameModal', () => {
|
||||
})
|
||||
|
||||
it('renders title, label, input and action buttons', () => {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('common.chat.renameConversation')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'common.chat.conversationName' })).toHaveValue(
|
||||
@@ -29,22 +29,22 @@ describe('RenameModal', () => {
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render when isShow is false', () => {
|
||||
render(<RenameModal {...defaultProps} isShow={false} />)
|
||||
it('does not render when open is false', () => {
|
||||
render(<RenameConversationDialog {...defaultProps} open={false} />)
|
||||
expect(screen.queryByText('common.chat.renameConversation')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when cancel is clicked', async () => {
|
||||
it('calls onOpenChange when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.cancel'))
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
expect(defaultProps.onOpenChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSave with updated name', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
@@ -54,33 +54,51 @@ describe('RenameModal', () => {
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith('Updated Name')
|
||||
})
|
||||
|
||||
it('does not resubmit while save is pending', async () => {
|
||||
it('keeps the draft read-only and blocks dismissal and resubmission while saving, then allows retry', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<RenameModal {...defaultProps} saveLoading />)
|
||||
const view = render(<RenameConversationDialog {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox', { name: 'common.chat.conversationName' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Draft name')
|
||||
const save = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(save)
|
||||
expect(defaultProps.onSave).toHaveBeenCalledExactlyOnceWith('Draft name')
|
||||
|
||||
await user.click(screen.getByRole('textbox', { name: 'common.chat.conversationName' }))
|
||||
await user.keyboard('{Enter}')
|
||||
view.rerender(<RenameConversationDialog {...defaultProps} saveLoading />)
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
expect(save).toHaveFocus()
|
||||
await user.type(input, 'ignored')
|
||||
await user.click(save)
|
||||
await user.keyboard('{Enter}{Escape}')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(defaultProps.onSave).toHaveBeenCalledTimes(1)
|
||||
expect(defaultProps.onOpenChange).not.toHaveBeenCalled()
|
||||
expect(input).toHaveValue('Draft name')
|
||||
|
||||
expect(defaultProps.onSave).not.toHaveBeenCalled()
|
||||
view.rerender(<RenameConversationDialog {...defaultProps} />)
|
||||
expect(input).not.toHaveAttribute('readonly')
|
||||
await user.click(save)
|
||||
expect(defaultProps.onSave).toHaveBeenCalledTimes(2)
|
||||
expect(defaultProps.onSave).toHaveBeenLastCalledWith('Draft name')
|
||||
})
|
||||
|
||||
it('calls onSave with initial name when unchanged', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith('Original Name')
|
||||
})
|
||||
|
||||
it('shows loading state when saveLoading is true', () => {
|
||||
render(<RenameModal {...defaultProps} saveLoading />)
|
||||
render(<RenameConversationDialog {...defaultProps} saveLoading />)
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
expectLoadingButton(saveButton)
|
||||
expect(saveButton.querySelector('.animate-spin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides loading state when saveLoading is false', () => {
|
||||
render(<RenameModal {...defaultProps} saveLoading={false} />)
|
||||
render(<RenameConversationDialog {...defaultProps} saveLoading={false} />)
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
expect(saveButton).not.toBeDisabled()
|
||||
expect(saveButton).not.toHaveAttribute('aria-busy')
|
||||
@@ -89,28 +107,29 @@ describe('RenameModal', () => {
|
||||
|
||||
it('keeps edited name when parent rerenders with different name prop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<RenameModal {...defaultProps} name="First" />)
|
||||
const { rerender } = render(<RenameConversationDialog {...defaultProps} name="First" />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Edited')
|
||||
|
||||
rerender(<RenameModal {...defaultProps} name="Second" />)
|
||||
rerender(<RenameConversationDialog {...defaultProps} name="Second" />)
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Edited')
|
||||
})
|
||||
|
||||
it('retains typed state after isShow false then true on same component instance', async () => {
|
||||
it('starts a fresh draft after closing finishes and the dialog opens again', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<RenameModal {...defaultProps} isShow />)
|
||||
const { rerender } = render(<RenameConversationDialog {...defaultProps} open />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Changed')
|
||||
|
||||
rerender(<RenameModal {...defaultProps} isShow={false} />)
|
||||
rerender(<RenameModal {...defaultProps} isShow />)
|
||||
rerender(<RenameConversationDialog {...defaultProps} open={false} />)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
rerender(<RenameConversationDialog {...defaultProps} name="Latest name" open />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Changed')
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Latest name')
|
||||
})
|
||||
|
||||
it('uses empty placeholder fallback when translation returns empty string', () => {
|
||||
@@ -130,7 +149,7 @@ describe('RenameModal', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
render(<RenameConversationDialog {...defaultProps} />)
|
||||
expect(screen.getByPlaceholderText('')).toBeInTheDocument()
|
||||
} finally {
|
||||
useTranslationSpy.mockRestore()
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { Ref } from 'react'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
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 { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import List from '@/app/components/base/chat/chat-with-history/sidebar/list'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { RenameConversationDialog } from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
|
||||
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
@@ -203,25 +203,25 @@ const Sidebar = ({ isPanel, toggleButtonRef }: Props) => {
|
||||
{deleteConversationContent}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancelButton>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
<RenameConversationDialog
|
||||
open={!!showRename}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleCancelRename()
|
||||
}}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ const Operation: FC<Props> = ({
|
||||
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
|
||||
data-active={isActive ? '' : undefined}
|
||||
className={cn(
|
||||
'pointer-events-none opacity-0 focus-visible:pointer-events-auto focus-visible:opacity-100 data-active:bg-state-accent-active data-active:text-text-accent data-active:hover:bg-state-accent-active-alt data-popup-open:pointer-events-auto data-popup-open:bg-state-base-hover data-popup-open:opacity-100 data-active:data-popup-open:bg-state-accent-active data-active:data-popup-open:text-text-accent',
|
||||
'pointer-events-none opacity-0 focus:pointer-events-auto focus:opacity-100 data-active:bg-state-accent-active data-active:text-text-accent data-active:hover:bg-state-accent-active-alt data-popup-open:pointer-events-auto data-popup-open:bg-state-base-hover data-popup-open:opacity-100 data-active:data-popup-open:bg-state-accent-active data-active:data-popup-open:text-text-accent',
|
||||
isItemHovering && 'pointer-events-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
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 { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type IRenameModalProps = {
|
||||
isShow: boolean
|
||||
type RenameConversationDialogProps = {
|
||||
open: boolean
|
||||
saveLoading: boolean
|
||||
name: string
|
||||
onClose: () => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (name: string) => void
|
||||
}
|
||||
|
||||
const RenameModal: FC<IRenameModalProps> = ({ isShow, saveLoading, name, onClose, onSave }) => {
|
||||
export function RenameConversationDialog({
|
||||
open,
|
||||
saveLoading,
|
||||
name,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: RenameConversationDialogProps) {
|
||||
const { t } = useTranslation(['common'])
|
||||
const [tempName, setTempName] = useState(name)
|
||||
const conversationNamePlaceholder =
|
||||
t(($) => $['chat.conversationNamePlaceholder'], { ns: 'common' }) || ''
|
||||
|
||||
return (
|
||||
<Dialog open={isShow} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
if (saveLoading) details.cancel()
|
||||
else onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['chat.renameConversation'], { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
<Form
|
||||
onFormSubmit={() => {
|
||||
if (!saveLoading) onSave(tempName)
|
||||
<Form<{ conversationName: string }>
|
||||
onFormSubmit={({ conversationName }) => {
|
||||
if (!saveLoading) onSave(conversationName)
|
||||
}}
|
||||
>
|
||||
<Field name="conversationName" className="mt-6">
|
||||
@@ -40,16 +48,16 @@ const RenameModal: FC<IRenameModalProps> = ({ isShow, saveLoading, name, onClose
|
||||
</FieldLabel>
|
||||
<Input
|
||||
className="h-10"
|
||||
value={tempName}
|
||||
onValueChange={setTempName}
|
||||
defaultValue={name}
|
||||
readOnly={saveLoading}
|
||||
placeholder={conversationNamePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="mt-10 flex justify-end">
|
||||
<Button type="button" className="mr-2 shrink-0" onClick={onClose}>
|
||||
<DialogClose disabled={saveLoading} render={<Button className="mr-2 shrink-0" />}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary" className="shrink-0" loading={saveLoading}>
|
||||
{t(($) => $['operation.save'], { ns: 'common' })}
|
||||
</Button>
|
||||
@@ -59,4 +67,3 @@ const RenameModal: FC<IRenameModalProps> = ({ isShow, saveLoading, name, onClose
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
export default React.memo(RenameModal)
|
||||
|
||||
@@ -5,7 +5,7 @@ import Chat from '../index'
|
||||
|
||||
// Keep the Chat/context/Operation/Log capability path real while omitting rich answer rendering.
|
||||
vi.mock('../answer', async () => {
|
||||
const { default: Operation } = await import('../answer/operation')
|
||||
const { Operation } = await import('../answer/operation')
|
||||
return {
|
||||
default: ({ item }: { item: ChatItem }) => (
|
||||
<Operation
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import type { ChatItem } from '../../../types'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import Operation from '../operation'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { ChatContextProvider } from '../../context-provider'
|
||||
import { Operation } from '../operation'
|
||||
|
||||
const feedback = vi.hoisted(() => ({ admin: false, onFeedback: vi.fn() }))
|
||||
|
||||
vi.mock('../../context', () => ({
|
||||
useChatContext: () => ({
|
||||
config: { supportFeedback: true, supportAnnotation: feedback.admin },
|
||||
onFeedback: feedback.onFeedback,
|
||||
}),
|
||||
}))
|
||||
const feedback = vi.hoisted(() => ({ onFeedback: vi.fn() }))
|
||||
|
||||
// These independently owned features do not participate in feedback focus restoration.
|
||||
vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({ default: () => null }))
|
||||
@@ -23,6 +18,51 @@ vi.mock('@/app/components/base/chat/chat/log', () => ({ default: () => null }))
|
||||
|
||||
const item: ChatItem = { id: 'answer', content: 'An answer', isAnswer: true }
|
||||
|
||||
function FeedbackAnswer({ admin = false }: { admin?: boolean }) {
|
||||
return (
|
||||
<ChatContextProvider
|
||||
chatList={[item]}
|
||||
config={{
|
||||
opening_statement: '',
|
||||
pre_prompt: '',
|
||||
prompt_type: PromptMode.simple,
|
||||
user_input_form: [],
|
||||
more_like_this: { enabled: false },
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
agent_mode: { enabled: false, tools: [] },
|
||||
dataset_configs: {
|
||||
retrieval_model: 'single',
|
||||
reranking_model: { reranking_provider_name: '', reranking_model_name: '' },
|
||||
top_k: 4,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: null,
|
||||
datasets: { datasets: [] },
|
||||
},
|
||||
supportFeedback: true,
|
||||
supportAnnotation: admin,
|
||||
}}
|
||||
onFeedback={feedback.onFeedback}
|
||||
>
|
||||
<button type="button">Before answer</button>
|
||||
<article aria-label="Answer" className="group relative m-10 h-32 w-96">
|
||||
<p>{item.content}</p>
|
||||
<Operation
|
||||
item={item}
|
||||
question="Question"
|
||||
index={0}
|
||||
maxSize={500}
|
||||
contentWidth={100}
|
||||
hasWorkflowProcess={false}
|
||||
/>
|
||||
</article>
|
||||
</ChatContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The exit transition is rendered by Chromium; the default test setup disables it.
|
||||
it.each(['cancel', 'submit'])(
|
||||
'preserves the feedback draft during the %s exit animation',
|
||||
@@ -30,22 +70,9 @@ it.each(['cancel', 'submit'])(
|
||||
const settings = globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean }
|
||||
const animationsDisabled = settings.BASE_UI_ANIMATIONS_DISABLED
|
||||
settings.BASE_UI_ANIMATIONS_DISABLED = false
|
||||
feedback.admin = false
|
||||
feedback.onFeedback.mockReset().mockResolvedValue(undefined)
|
||||
try {
|
||||
const screen = await render(
|
||||
<article aria-label="Answer" className="group relative m-10 h-32 w-96">
|
||||
<p>{item.content}</p>
|
||||
<Operation
|
||||
item={item}
|
||||
question="Question"
|
||||
index={0}
|
||||
maxSize={500}
|
||||
contentWidth={100}
|
||||
hasWorkflowProcess={false}
|
||||
/>
|
||||
</article>,
|
||||
)
|
||||
const screen = await render(<FeedbackAnswer />)
|
||||
await screen.getByRole('article', { name: 'Answer' }).hover()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: 'appLog.table.header.userRate: appLog.detail.operation.dislike',
|
||||
@@ -100,23 +127,7 @@ describe('Feedback dialog focus', () => {
|
||||
])(
|
||||
'returns to the visible feedback button after $close (admin: $admin)',
|
||||
async ({ admin, close }) => {
|
||||
feedback.admin = admin
|
||||
const screen = await render(
|
||||
<>
|
||||
<button type="button">Before answer</button>
|
||||
<article aria-label="Answer" className="group relative m-10 h-32 w-96">
|
||||
<p>{item.content}</p>
|
||||
<Operation
|
||||
item={item}
|
||||
question="Question"
|
||||
index={0}
|
||||
maxSize={500}
|
||||
contentWidth={100}
|
||||
hasWorkflowProcess={false}
|
||||
/>
|
||||
</article>
|
||||
</>,
|
||||
)
|
||||
const screen = await render(<FeedbackAnswer admin={admin} />)
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: `${admin ? 'appLog.table.header.adminRate' : 'appLog.table.header.userRate'}: appLog.detail.operation.dislike`,
|
||||
})
|
||||
@@ -151,3 +162,77 @@ describe('Feedback dialog focus', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['false', 'reject'] as const)(
|
||||
'keeps the pending feedback focused and retries the draft after a %s failure',
|
||||
async (failure) => {
|
||||
let resolveFeedback!: (value: boolean) => void
|
||||
let rejectFeedback!: (reason: Error) => void
|
||||
feedback.onFeedback
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve, reject) => {
|
||||
resolveFeedback = resolve
|
||||
rejectFeedback = reject
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined)
|
||||
const screen = await render(<FeedbackAnswer />)
|
||||
await screen.getByRole('article', { name: 'Answer' }).hover()
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: 'appLog.table.header.userRate: appLog.detail.operation.dislike',
|
||||
})
|
||||
await trigger.click()
|
||||
const dialog = screen.getByRole('dialog', { name: 'common.feedback.title' })
|
||||
const textbox = dialog.getByRole('textbox', { name: 'common.feedback.content' })
|
||||
await textbox.fill('Please explain')
|
||||
await userEvent.keyboard('{End}{Enter}the answer')
|
||||
const draft = 'Please explain\nthe answer'
|
||||
await expect.element(textbox).toHaveValue(draft)
|
||||
expect(feedback.onFeedback).not.toHaveBeenCalled()
|
||||
|
||||
const submit = dialog.getByRole('button', { name: 'common.operation.submit' })
|
||||
await submit.click()
|
||||
await expect.element(submit).toHaveFocus()
|
||||
await expect.element(submit).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(textbox).toHaveAttribute('readonly')
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: 'common.operation.close' }))
|
||||
.toBeDisabled()
|
||||
await expect
|
||||
.element(dialog.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
.toBeDisabled()
|
||||
await userEvent.keyboard('{Enter}{Enter}{Escape}')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
expect(feedback.onFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(feedback.onFeedback).toHaveBeenCalledWith('answer', {
|
||||
rating: 'dislike',
|
||||
content: draft,
|
||||
})
|
||||
|
||||
const outside = document.elementFromPoint(4, 4)!
|
||||
await userEvent.click(outside, { position: { x: 4, y: 4 } })
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await userEvent.tab()
|
||||
await expect.poll(() => dialog.element().contains(document.activeElement)).toBe(true)
|
||||
await textbox.click()
|
||||
await userEvent.keyboard('ignored')
|
||||
await expect.element(textbox).toHaveValue(draft)
|
||||
|
||||
if (failure === 'false') resolveFeedback(false)
|
||||
else rejectFeedback(new Error('Feedback request failed'))
|
||||
await expect.element(textbox).not.toHaveAttribute('readonly')
|
||||
await expect.element(submit).not.toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await expect.element(textbox).toHaveValue(draft)
|
||||
await submit.click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
await expect.element(trigger).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(feedback.onFeedback).toHaveBeenCalledTimes(2)
|
||||
expect(feedback.onFeedback).toHaveBeenLastCalledWith('answer', {
|
||||
rating: 'dislike',
|
||||
content: draft,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ChatConfig, ChatItem } from '../../../types'
|
||||
import type { ChatContextValue } from '../../context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Operation from '../operation'
|
||||
import { ChatContextProvider } from '../../context-provider'
|
||||
import { Operation } from '../operation'
|
||||
|
||||
const { mockSetShowAnnotationFullModal, mockT, mockAddAnnotation } = vi.hoisted(() => {
|
||||
return {
|
||||
@@ -151,10 +152,6 @@ const mockContextValue: ChatContextValue = {
|
||||
readonly: false,
|
||||
}
|
||||
|
||||
vi.mock('../../context', () => ({
|
||||
useChatContext: () => mockContextValue,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const { withSelectorKey } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
@@ -219,6 +216,11 @@ describe('Operation', () => {
|
||||
<div className="group">
|
||||
<Operation {...props} />
|
||||
</div>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<ChatContextProvider {...mockContextValue}>{children}</ChatContextProvider>
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -237,6 +239,77 @@ describe('Operation', () => {
|
||||
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
|
||||
})
|
||||
|
||||
describe('feedback submission sessions', () => {
|
||||
it.each([
|
||||
{ admin: false, failure: 'false' },
|
||||
{ admin: true, failure: 'false' },
|
||||
{ admin: false, failure: 'rejection' },
|
||||
{ admin: true, failure: 'rejection' },
|
||||
])(
|
||||
'preserves the draft after $failure and retries successfully (admin: $admin)',
|
||||
async ({ admin, failure }) => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportFeedback: true,
|
||||
supportAnnotation: admin,
|
||||
})
|
||||
const onFeedback = vi.fn<NonNullable<ChatContextValue['onFeedback']>>()
|
||||
if (failure === 'false') onFeedback.mockResolvedValueOnce(false)
|
||||
else onFeedback.mockRejectedValueOnce(new Error('Feedback failed'))
|
||||
onFeedback.mockResolvedValueOnce(true)
|
||||
mockContextValue.onFeedback = onFeedback
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const name = `${admin ? 'table.header.adminRate' : 'table.header.userRate'}: detail.operation.dislike`
|
||||
await user.click(screen.getByRole('button', { name }))
|
||||
const input = screen.getByRole('textbox', { name: 'feedback.content' })
|
||||
await user.type(input, 'Needs more detail')
|
||||
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
|
||||
await waitFor(() => expect(input).not.toHaveAttribute('readonly'))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(input).toHaveValue('Needs more detail')
|
||||
await user.type(input, ' please')
|
||||
await user.click(screen.getByRole('button', { name: 'operation.submit' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(onFeedback).toHaveBeenLastCalledWith('msg-1', {
|
||||
rating: 'dislike',
|
||||
content: 'Needs more detail please',
|
||||
})
|
||||
expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'true')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps one pending submission open and prevents edits or duplicate submissions', async () => {
|
||||
let resolve!: () => void
|
||||
const pending = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const onFeedback = vi.fn().mockReturnValue(pending)
|
||||
mockContextValue.onFeedback = onFeedback
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'table.header.userRate: detail.operation.dislike' }),
|
||||
)
|
||||
const input = screen.getByRole('textbox', { name: 'feedback.content' })
|
||||
await user.type(input, 'Original draft')
|
||||
const submit = screen.getByRole('button', { name: 'operation.submit' })
|
||||
await user.click(submit)
|
||||
expect(submit).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
await user.keyboard('{Escape}')
|
||||
await user.click(screen.getByRole('button', { name: 'operation.cancel' }))
|
||||
await user.click(screen.getByRole('button', { name: 'operation.close' }))
|
||||
await user.click(submit)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(onFeedback).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
resolve()
|
||||
await pending
|
||||
})
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should hide action buttons for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
|
||||
@@ -18,7 +18,7 @@ import BasicContent from './basic-content'
|
||||
import HumanInputFilledFormList from './human-input-filled-form-list'
|
||||
import HumanInputFormList from './human-input-form-list'
|
||||
import More from './more'
|
||||
import Operation from './operation'
|
||||
import { Operation } from './operation'
|
||||
import ReasoningPanel from './reasoning-panel'
|
||||
import SuggestedQuestions from './suggested-questions'
|
||||
import WorkflowProcessItem from './workflow-process'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DialogActions } from '@langgenius/dify-ui/dialog'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { ChatItem, Feedback } from '../../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
@@ -78,456 +79,478 @@ const FeedbackTooltip = ({ content, children }: FeedbackTooltipProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
function Operation({
|
||||
answerActionPosition = 'auto',
|
||||
item,
|
||||
question,
|
||||
index,
|
||||
maxSize,
|
||||
contentWidth,
|
||||
hasWorkflowProcess,
|
||||
noChatInput,
|
||||
}: OperationProps) {
|
||||
const { t } = useTranslation(['appLog', 'common'])
|
||||
const {
|
||||
config,
|
||||
onOpenLog,
|
||||
canOpenLog,
|
||||
onAnnotationAdded,
|
||||
onAnnotationEdited,
|
||||
onAnnotationRemoved,
|
||||
onFeedback,
|
||||
onRegenerate,
|
||||
showRegenerate,
|
||||
readonly,
|
||||
} = useChatContext()
|
||||
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
||||
// Submitting replaces the dialog trigger with the current rating button.
|
||||
const userFeedbackRef = useRef<HTMLButtonElement>(null)
|
||||
const adminFeedbackRef = useRef<HTMLButtonElement>(null)
|
||||
const [isShowFeedbackModal, setIsShowFeedbackModal] = useState(false)
|
||||
type FeedbackFormProps = {
|
||||
isSubmitting: boolean
|
||||
onSubmit: (content: string) => Promise<void>
|
||||
}
|
||||
|
||||
function FeedbackForm({ isSubmitting, onSubmit }: FeedbackFormProps) {
|
||||
const { t } = useTranslation(['common'])
|
||||
const [feedbackContent, setFeedbackContent] = useState('')
|
||||
const { id, isOpeningStatement, annotation, feedback, adminFeedback, humanInputFormDataList } =
|
||||
item
|
||||
const [userFeedbackOverride, setUserFeedbackOverride] = useState<Feedback>()
|
||||
const [adminFeedbackOverride, setAdminFeedbackOverride] = useState<Feedback>()
|
||||
const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user')
|
||||
const feedbackTextareaId = useId()
|
||||
|
||||
const content = getPublicResponseContent(item)
|
||||
const hasPublicContent = !!content.trim()
|
||||
const availableLogAction = onOpenLog && (canOpenLog?.(item) ?? true) ? onOpenLog : undefined
|
||||
|
||||
const displayUserFeedback = userFeedbackOverride ?? feedback
|
||||
const displayAdminFeedback = adminFeedbackOverride ?? adminFeedback
|
||||
|
||||
const hasUserFeedback = !!displayUserFeedback?.rating
|
||||
const hasAdminFeedback = !!displayAdminFeedback?.rating
|
||||
|
||||
const shouldShowUserFeedbackBar =
|
||||
!isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation
|
||||
const shouldShowAdminFeedbackBar =
|
||||
!isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation
|
||||
const canManageAnnotation =
|
||||
!readonly && !!onAnnotationAdded && !!onAnnotationEdited && !!onAnnotationRemoved
|
||||
const shouldShowAnnotationAction =
|
||||
canManageAnnotation &&
|
||||
hasPublicContent &&
|
||||
!!config?.supportAnnotation &&
|
||||
!!config.annotation_reply?.enabled &&
|
||||
!humanInputFormDataList?.length
|
||||
|
||||
const userFeedbackLabel =
|
||||
t(($) => $['table.header.userRate'], { ns: 'appLog' }) || 'User feedback'
|
||||
const adminFeedbackLabel =
|
||||
t(($) => $['table.header.adminRate'], { ns: 'appLog' }) || 'Admin feedback'
|
||||
const likeLabel = t(($) => $['detail.operation.like'], { ns: 'appLog' }) || 'Like'
|
||||
const dislikeLabel = t(($) => $['detail.operation.dislike'], { ns: 'appLog' }) || 'Dislike'
|
||||
const copyLabel = t(($) => $['operation.copy'], { ns: 'common' }) || 'Copy'
|
||||
const regenerateLabel = t(($) => $['operation.regenerate'], { ns: 'common' }) || 'Regenerate'
|
||||
|
||||
const buildFeedbackTooltip = (
|
||||
feedbackData?: Feedback | null,
|
||||
label: string = userFeedbackLabel,
|
||||
) => {
|
||||
if (!feedbackData?.rating) return label
|
||||
|
||||
const ratingLabel =
|
||||
feedbackData.rating === 'like'
|
||||
? t(($) => $['detail.operation.like'], { ns: 'appLog' }) || 'like'
|
||||
: t(($) => $['detail.operation.dislike'], { ns: 'appLog' }) || 'dislike'
|
||||
const feedbackText = feedbackData.content?.trim()
|
||||
|
||||
if (feedbackText) return `${label}: ${ratingLabel} - ${feedbackText}`
|
||||
|
||||
return `${label}: ${ratingLabel}`
|
||||
}
|
||||
|
||||
const handleFeedback = async (
|
||||
rating: 'like' | 'dislike' | null,
|
||||
content?: string,
|
||||
target: 'user' | 'admin' = 'user',
|
||||
) => {
|
||||
if (!config?.supportFeedback || !onFeedback) return false
|
||||
|
||||
try {
|
||||
await onFeedback(id, { rating, content })
|
||||
|
||||
const nextFeedback = rating === null ? { rating: null } : { rating, content }
|
||||
|
||||
if (target === 'admin') setAdminFeedbackOverride(nextFeedback)
|
||||
else setUserFeedbackOverride(nextFeedback)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLikeClick = (target: 'user' | 'admin') => {
|
||||
void handleFeedback('like', undefined, target)
|
||||
}
|
||||
|
||||
const handleFeedbackSubmit = async () => {
|
||||
const succeeded = await handleFeedback('dislike', feedbackContent, feedbackTarget)
|
||||
if (!succeeded) return
|
||||
|
||||
setIsShowFeedbackModal(false)
|
||||
}
|
||||
|
||||
const handleFeedbackCancel = () => {
|
||||
setIsShowFeedbackModal(false)
|
||||
}
|
||||
|
||||
const operationWidth = useMemo(() => {
|
||||
let width = 0
|
||||
if (!isOpeningStatement) width += 26
|
||||
if (!isOpeningStatement && availableLogAction) width += 28 + 8
|
||||
if (!isOpeningStatement && config?.text_to_speech?.enabled && hasPublicContent) width += 26
|
||||
if (!isOpeningStatement && shouldShowAnnotationAction) width += 26
|
||||
if (shouldShowUserFeedbackBar) width += hasUserFeedback ? 28 + 8 : 60 + 8
|
||||
if (shouldShowAdminFeedbackBar)
|
||||
width += (hasAdminFeedback ? 28 : 60) + 8 + (hasUserFeedback ? 28 : 0)
|
||||
|
||||
return width
|
||||
}, [
|
||||
config?.text_to_speech?.enabled,
|
||||
hasAdminFeedback,
|
||||
hasPublicContent,
|
||||
hasUserFeedback,
|
||||
isOpeningStatement,
|
||||
availableLogAction,
|
||||
shouldShowAdminFeedbackBar,
|
||||
shouldShowAnnotationAction,
|
||||
shouldShowUserFeedbackBar,
|
||||
])
|
||||
|
||||
const positionRight = useMemo(
|
||||
() => answerActionPosition === 'auto' && operationWidth < maxSize,
|
||||
[answerActionPosition, operationWidth, maxSize],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute flex justify-end gap-1',
|
||||
hasWorkflowProcess && 'right-2 -bottom-4',
|
||||
!positionRight && 'right-2 -bottom-4',
|
||||
!hasWorkflowProcess && positionRight && 'top-2.25!',
|
||||
)}
|
||||
style={!hasWorkflowProcess && positionRight ? { left: contentWidth + 8 } : {}}
|
||||
data-testid="operation-bar"
|
||||
>
|
||||
<Dialog
|
||||
open={isShowFeedbackModal}
|
||||
onOpenChange={setIsShowFeedbackModal}
|
||||
onOpenChangeComplete={(open) => {
|
||||
if (!open) setFeedbackContent('')
|
||||
}}
|
||||
<form
|
||||
className="flex max-h-[80dvh] flex-col"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!isSubmitting) void onSubmit(feedbackContent)
|
||||
}}
|
||||
>
|
||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['feedback.title'], { ns: 'common' }) || 'Provide Feedback'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['feedback.subtitle'], { ns: 'common' }) ||
|
||||
'Please tell us what went wrong with this response'}
|
||||
</DialogDescription>
|
||||
<DialogClose
|
||||
disabled={isSubmitting}
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
size="lg"
|
||||
className="absolute top-5 right-5"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
<label
|
||||
htmlFor={feedbackTextareaId}
|
||||
className="mb-2 block system-sm-semibold text-text-secondary"
|
||||
>
|
||||
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
|
||||
hasUserFeedback ? 'flex' : feedbackActionsClassName,
|
||||
)}
|
||||
>
|
||||
{hasUserFeedback ? (
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
>
|
||||
<Toggle
|
||||
ref={userFeedbackRef}
|
||||
className={
|
||||
displayUserFeedback?.rating === 'like'
|
||||
? accentPressedClassName
|
||||
: destructivePressedClassName
|
||||
}
|
||||
pressed
|
||||
onPressedChange={(pressed) =>
|
||||
!pressed && void handleFeedback(null, undefined, 'user')
|
||||
}
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={`${userFeedbackLabel}: ${displayUserFeedback?.rating === 'like' ? likeLabel : dislikeLabel}`}
|
||||
>
|
||||
{displayUserFeedback?.rating === 'like' ? (
|
||||
<span aria-hidden="true" className="i-ri-thumb-up-line size-4" />
|
||||
) : (
|
||||
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</FeedbackTooltip>
|
||||
) : (
|
||||
<>
|
||||
<Toggle
|
||||
className={accentPressedClassName}
|
||||
pressed={false}
|
||||
onPressedChange={(pressed) => pressed && handleLikeClick('user')}
|
||||
render={
|
||||
<IconButton aria-label={`${userFeedbackLabel}: ${likeLabel}`}>
|
||||
<span aria-hidden="true" className="i-ri-thumb-up-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger
|
||||
ref={userFeedbackRef}
|
||||
onClick={() => setFeedbackTarget('user')}
|
||||
render={
|
||||
<IconButton aria-label={`${userFeedbackLabel}: ${dislikeLabel}`}>
|
||||
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{t(($) => $['feedback.content'], { ns: 'common' }) || 'Feedback Content'}
|
||||
</label>
|
||||
<Textarea
|
||||
id={feedbackTextareaId}
|
||||
name="feedback-content"
|
||||
value={feedbackContent}
|
||||
readOnly={isSubmitting}
|
||||
onValueChange={(value) => setFeedbackContent(value)}
|
||||
placeholder={
|
||||
t(($) => $['feedback.placeholder'], { ns: 'common' }) ||
|
||||
'Please describe what went wrong or how we can improve…'
|
||||
}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end p-6 pt-5">
|
||||
<DialogClose disabled={isSubmitting} render={<Button />}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' }) || 'Cancel'}
|
||||
</DialogClose>
|
||||
<Button type="submit" className="ml-2" variant="primary" loading={isSubmitting}>
|
||||
{t(($) => $['operation.submit'], { ns: 'common' }) || 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export const Operation = memo(
|
||||
({
|
||||
answerActionPosition = 'auto',
|
||||
item,
|
||||
question,
|
||||
index,
|
||||
maxSize,
|
||||
contentWidth,
|
||||
hasWorkflowProcess,
|
||||
noChatInput,
|
||||
}: OperationProps) => {
|
||||
const { t } = useTranslation(['appLog', 'common'])
|
||||
const {
|
||||
config,
|
||||
onOpenLog,
|
||||
canOpenLog,
|
||||
onAnnotationAdded,
|
||||
onAnnotationEdited,
|
||||
onAnnotationRemoved,
|
||||
onFeedback,
|
||||
onRegenerate,
|
||||
showRegenerate,
|
||||
readonly,
|
||||
} = useChatContext()
|
||||
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
||||
// Submitting replaces the dialog trigger with the current rating button.
|
||||
const userFeedbackRef = useRef<HTMLButtonElement>(null)
|
||||
const adminFeedbackRef = useRef<HTMLButtonElement>(null)
|
||||
const feedbackActionsRef = useRef<DialogActions>(null)
|
||||
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false)
|
||||
const { id, isOpeningStatement, annotation, feedback, adminFeedback, humanInputFormDataList } =
|
||||
item
|
||||
const [userFeedbackOverride, setUserFeedbackOverride] = useState<Feedback>()
|
||||
const [adminFeedbackOverride, setAdminFeedbackOverride] = useState<Feedback>()
|
||||
const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user')
|
||||
|
||||
const content = getPublicResponseContent(item)
|
||||
const hasPublicContent = !!content.trim()
|
||||
const availableLogAction = onOpenLog && (canOpenLog?.(item) ?? true) ? onOpenLog : undefined
|
||||
|
||||
const displayUserFeedback = userFeedbackOverride ?? feedback
|
||||
const displayAdminFeedback = adminFeedbackOverride ?? adminFeedback
|
||||
|
||||
const hasUserFeedback = !!displayUserFeedback?.rating
|
||||
const hasAdminFeedback = !!displayAdminFeedback?.rating
|
||||
|
||||
const shouldShowUserFeedbackBar =
|
||||
!isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation
|
||||
const shouldShowAdminFeedbackBar =
|
||||
!isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation
|
||||
const canManageAnnotation =
|
||||
!readonly && !!onAnnotationAdded && !!onAnnotationEdited && !!onAnnotationRemoved
|
||||
const shouldShowAnnotationAction =
|
||||
canManageAnnotation &&
|
||||
hasPublicContent &&
|
||||
!!config?.supportAnnotation &&
|
||||
!!config.annotation_reply?.enabled &&
|
||||
!humanInputFormDataList?.length
|
||||
|
||||
const userFeedbackLabel =
|
||||
t(($) => $['table.header.userRate'], { ns: 'appLog' }) || 'User feedback'
|
||||
const adminFeedbackLabel =
|
||||
t(($) => $['table.header.adminRate'], { ns: 'appLog' }) || 'Admin feedback'
|
||||
const likeLabel = t(($) => $['detail.operation.like'], { ns: 'appLog' }) || 'Like'
|
||||
const dislikeLabel = t(($) => $['detail.operation.dislike'], { ns: 'appLog' }) || 'Dislike'
|
||||
const copyLabel = t(($) => $['operation.copy'], { ns: 'common' }) || 'Copy'
|
||||
const regenerateLabel = t(($) => $['operation.regenerate'], { ns: 'common' }) || 'Regenerate'
|
||||
|
||||
const buildFeedbackTooltip = (
|
||||
feedbackData?: Feedback | null,
|
||||
label: string = userFeedbackLabel,
|
||||
) => {
|
||||
if (!feedbackData?.rating) return label
|
||||
|
||||
const ratingLabel =
|
||||
feedbackData.rating === 'like'
|
||||
? t(($) => $['detail.operation.like'], { ns: 'appLog' }) || 'like'
|
||||
: t(($) => $['detail.operation.dislike'], { ns: 'appLog' }) || 'dislike'
|
||||
const feedbackText = feedbackData.content?.trim()
|
||||
|
||||
if (feedbackText) return `${label}: ${ratingLabel} - ${feedbackText}`
|
||||
|
||||
return `${label}: ${ratingLabel}`
|
||||
}
|
||||
|
||||
const handleFeedback = async (
|
||||
rating: 'like' | 'dislike' | null,
|
||||
content?: string,
|
||||
target: 'user' | 'admin' = 'user',
|
||||
) => {
|
||||
if (!config?.supportFeedback || !onFeedback) return false
|
||||
|
||||
try {
|
||||
const result = await onFeedback(id, { rating, content })
|
||||
if (result === false) return false
|
||||
|
||||
const nextFeedback = rating === null ? { rating: null } : { rating, content }
|
||||
|
||||
if (target === 'admin') setAdminFeedbackOverride(nextFeedback)
|
||||
else setUserFeedbackOverride(nextFeedback)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLikeClick = (target: 'user' | 'admin') => {
|
||||
void handleFeedback('like', undefined, target)
|
||||
}
|
||||
|
||||
const handleFeedbackSubmit = async (content: string) => {
|
||||
if (isSubmittingFeedback) return
|
||||
setIsSubmittingFeedback(true)
|
||||
try {
|
||||
const succeeded = await handleFeedback('dislike', content, feedbackTarget)
|
||||
if (succeeded) feedbackActionsRef.current?.close()
|
||||
} finally {
|
||||
setIsSubmittingFeedback(false)
|
||||
}
|
||||
}
|
||||
|
||||
const operationWidth = useMemo(() => {
|
||||
let width = 0
|
||||
if (!isOpeningStatement) width += 26
|
||||
if (!isOpeningStatement && availableLogAction) width += 28 + 8
|
||||
if (!isOpeningStatement && config?.text_to_speech?.enabled && hasPublicContent) width += 26
|
||||
if (!isOpeningStatement && shouldShowAnnotationAction) width += 26
|
||||
if (shouldShowUserFeedbackBar) width += hasUserFeedback ? 28 + 8 : 60 + 8
|
||||
if (shouldShowAdminFeedbackBar)
|
||||
width += (hasAdminFeedback ? 28 : 60) + 8 + (hasUserFeedback ? 28 : 0)
|
||||
|
||||
return width
|
||||
}, [
|
||||
config?.text_to_speech?.enabled,
|
||||
hasAdminFeedback,
|
||||
hasPublicContent,
|
||||
hasUserFeedback,
|
||||
isOpeningStatement,
|
||||
availableLogAction,
|
||||
shouldShowAdminFeedbackBar,
|
||||
shouldShowAnnotationAction,
|
||||
shouldShowUserFeedbackBar,
|
||||
])
|
||||
|
||||
const positionRight = useMemo(
|
||||
() => answerActionPosition === 'auto' && operationWidth < maxSize,
|
||||
[answerActionPosition, operationWidth, maxSize],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute flex justify-end gap-1',
|
||||
hasWorkflowProcess && 'right-2 -bottom-4',
|
||||
!positionRight && 'right-2 -bottom-4',
|
||||
!hasWorkflowProcess && positionRight && 'top-2.25!',
|
||||
)}
|
||||
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
|
||||
hasAdminFeedback || hasUserFeedback ? 'flex' : feedbackActionsClassName,
|
||||
)}
|
||||
>
|
||||
{displayUserFeedback?.rating && (
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
className={cn(
|
||||
'inline-flex size-6 items-center justify-center rounded-lg p-0.5',
|
||||
displayUserFeedback.rating === 'like'
|
||||
? 'bg-state-accent-active text-text-accent'
|
||||
: 'bg-state-destructive-hover text-text-destructive',
|
||||
)}
|
||||
style={!hasWorkflowProcess && positionRight ? { left: contentWidth + 8 } : {}}
|
||||
data-testid="operation-bar"
|
||||
>
|
||||
<Dialog
|
||||
actionsRef={feedbackActionsRef}
|
||||
onOpenChange={(open, eventDetails) => {
|
||||
if (!open && isSubmittingFeedback && eventDetails.reason !== 'imperative-action')
|
||||
eventDetails.cancel()
|
||||
}}
|
||||
>
|
||||
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
|
||||
hasUserFeedback ? 'flex' : feedbackActionsClassName,
|
||||
)}
|
||||
>
|
||||
{hasUserFeedback ? (
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
>
|
||||
<Toggle
|
||||
ref={userFeedbackRef}
|
||||
className={
|
||||
displayUserFeedback?.rating === 'like'
|
||||
? accentPressedClassName
|
||||
: destructivePressedClassName
|
||||
}
|
||||
pressed
|
||||
onPressedChange={(pressed) =>
|
||||
!pressed && void handleFeedback(null, undefined, 'user')
|
||||
}
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={`${userFeedbackLabel}: ${displayUserFeedback?.rating === 'like' ? likeLabel : dislikeLabel}`}
|
||||
>
|
||||
{displayUserFeedback?.rating === 'like' ? (
|
||||
<span aria-hidden="true" className="i-ri-thumb-up-line size-4" />
|
||||
) : (
|
||||
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</FeedbackTooltip>
|
||||
) : (
|
||||
<>
|
||||
<Toggle
|
||||
className={accentPressedClassName}
|
||||
pressed={false}
|
||||
onPressedChange={(pressed) => pressed && handleLikeClick('user')}
|
||||
render={
|
||||
<IconButton aria-label={`${userFeedbackLabel}: ${likeLabel}`}>
|
||||
<span aria-hidden="true" className="i-ri-thumb-up-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<DialogTrigger
|
||||
ref={userFeedbackRef}
|
||||
onClick={() => setFeedbackTarget('user')}
|
||||
render={
|
||||
<IconButton aria-label={`${userFeedbackLabel}: ${dislikeLabel}`}>
|
||||
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
|
||||
hasAdminFeedback || hasUserFeedback ? 'flex' : feedbackActionsClassName,
|
||||
)}
|
||||
>
|
||||
{displayUserFeedback?.rating && (
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
aria-label={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
|
||||
className={cn(
|
||||
'size-4',
|
||||
'inline-flex size-6 items-center justify-center rounded-lg p-0.5',
|
||||
displayUserFeedback.rating === 'like'
|
||||
? 'i-ri-thumb-up-line'
|
||||
: 'i-ri-thumb-down-line',
|
||||
? 'bg-state-accent-active text-text-accent'
|
||||
: 'bg-state-destructive-hover text-text-destructive',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</FeedbackTooltip>
|
||||
)}
|
||||
|
||||
{displayUserFeedback?.rating && (
|
||||
<div className="mx-1 h-3 w-[0.5px] bg-components-actionbar-border" />
|
||||
)}
|
||||
{hasAdminFeedback ? (
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayAdminFeedback, adminFeedbackLabel)}
|
||||
>
|
||||
<Toggle
|
||||
ref={adminFeedbackRef}
|
||||
className={
|
||||
displayAdminFeedback?.rating === 'like'
|
||||
? accentPressedClassName
|
||||
: destructivePressedClassName
|
||||
}
|
||||
pressed
|
||||
onPressedChange={(pressed) =>
|
||||
!pressed && void handleFeedback(null, undefined, 'admin')
|
||||
}
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={`${adminFeedbackLabel}: ${displayAdminFeedback?.rating === 'like' ? likeLabel : dislikeLabel}`}
|
||||
>
|
||||
{displayAdminFeedback?.rating === 'like' ? (
|
||||
<span aria-hidden="true" className="i-ri-thumb-up-line size-4" />
|
||||
) : (
|
||||
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-4',
|
||||
displayUserFeedback.rating === 'like'
|
||||
? 'i-ri-thumb-up-line'
|
||||
: 'i-ri-thumb-down-line',
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</FeedbackTooltip>
|
||||
) : (
|
||||
<>
|
||||
/>
|
||||
</span>
|
||||
</FeedbackTooltip>
|
||||
)}
|
||||
|
||||
{displayUserFeedback?.rating && (
|
||||
<div className="mx-1 h-3 w-[0.5px] bg-components-actionbar-border" />
|
||||
)}
|
||||
{hasAdminFeedback ? (
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayAdminFeedback, adminFeedbackLabel)}
|
||||
>
|
||||
<Toggle
|
||||
className={accentPressedClassName}
|
||||
pressed={false}
|
||||
onPressedChange={(pressed) => pressed && handleLikeClick('admin')}
|
||||
render={
|
||||
<IconButton aria-label={`${adminFeedbackLabel}: ${likeLabel}`}>
|
||||
<span aria-hidden="true" className="i-ri-thumb-up-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</FeedbackTooltip>
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayAdminFeedback, adminFeedbackLabel)}
|
||||
>
|
||||
<DialogTrigger
|
||||
ref={adminFeedbackRef}
|
||||
onClick={() => setFeedbackTarget('admin')}
|
||||
className={
|
||||
displayAdminFeedback?.rating === 'like'
|
||||
? accentPressedClassName
|
||||
: destructivePressedClassName
|
||||
}
|
||||
pressed
|
||||
onPressedChange={(pressed) =>
|
||||
!pressed && void handleFeedback(null, undefined, 'admin')
|
||||
}
|
||||
render={
|
||||
<IconButton aria-label={`${adminFeedbackLabel}: ${dislikeLabel}`}>
|
||||
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
|
||||
<IconButton
|
||||
aria-label={`${adminFeedbackLabel}: ${displayAdminFeedback?.rating === 'like' ? likeLabel : dislikeLabel}`}
|
||||
>
|
||||
{displayAdminFeedback?.rating === 'like' ? (
|
||||
<span aria-hidden="true" className="i-ri-thumb-up-line size-4" />
|
||||
) : (
|
||||
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</FeedbackTooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayAdminFeedback, adminFeedbackLabel)}
|
||||
>
|
||||
<Toggle
|
||||
className={accentPressedClassName}
|
||||
pressed={false}
|
||||
onPressedChange={(pressed) => pressed && handleLikeClick('admin')}
|
||||
render={
|
||||
<IconButton aria-label={`${adminFeedbackLabel}: ${likeLabel}`}>
|
||||
<span aria-hidden="true" className="i-ri-thumb-up-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</FeedbackTooltip>
|
||||
<FeedbackTooltip
|
||||
content={buildFeedbackTooltip(displayAdminFeedback, adminFeedbackLabel)}
|
||||
>
|
||||
<DialogTrigger
|
||||
ref={adminFeedbackRef}
|
||||
onClick={() => setFeedbackTarget('admin')}
|
||||
render={
|
||||
<IconButton aria-label={`${adminFeedbackLabel}: ${dislikeLabel}`}>
|
||||
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</FeedbackTooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogContent
|
||||
finalFocus={feedbackTarget === 'user' ? userFeedbackRef : adminFeedbackRef}
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="p-0"
|
||||
>
|
||||
<FeedbackForm isSubmitting={isSubmittingFeedback} onSubmit={handleFeedbackSubmit} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{availableLogAction && !isOpeningStatement && (
|
||||
<div className={cn('hidden', answerActiveBlockClassName)}>
|
||||
<Log logItem={item} onOpenLog={availableLogAction} />
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
|
||||
answerActiveFlexClassName,
|
||||
)}
|
||||
data-testid="operation-actions"
|
||||
>
|
||||
{config?.text_to_speech?.enabled &&
|
||||
hasPublicContent &&
|
||||
!humanInputFormDataList?.length && (
|
||||
<NewAudioButton id={id} value={content} voice={config?.text_to_speech?.voice} />
|
||||
)}
|
||||
{hasPublicContent && !humanInputFormDataList?.length && (
|
||||
<IconButton
|
||||
aria-label={copyLabel}
|
||||
onClick={() => {
|
||||
copy(content)
|
||||
toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' }))
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-clipboard-line size-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
{(!noChatInput || showRegenerate) && (
|
||||
<IconButton aria-label={regenerateLabel} onClick={() => onRegenerate?.(item)}>
|
||||
<span aria-hidden="true" className="i-ri-reset-left-line size-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
{shouldShowAnnotationAction && (
|
||||
<AnnotationCtrlButton
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
cached={!!annotation?.id}
|
||||
query={question}
|
||||
answer={content}
|
||||
onAdded={(id, authorName) =>
|
||||
onAnnotationAdded?.(id, authorName, question, content, index)
|
||||
}
|
||||
onEdit={() => setIsShowReplyModal(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogContent
|
||||
finalFocus={feedbackTarget === 'user' ? userFeedbackRef : adminFeedbackRef}
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="p-0"
|
||||
>
|
||||
<div className="flex max-h-[80dvh] flex-col">
|
||||
<div className="relative shrink-0 p-6 pr-14 pb-3">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['feedback.title'], { ns: 'common' }) || 'Provide Feedback'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['feedback.subtitle'], { ns: 'common' }) ||
|
||||
'Please tell us what went wrong with this response'}
|
||||
</DialogDescription>
|
||||
<DialogClose
|
||||
render={
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
size="lg"
|
||||
className="absolute top-5 right-5"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
<label
|
||||
htmlFor={feedbackTextareaId}
|
||||
className="mb-2 block system-sm-semibold text-text-secondary"
|
||||
>
|
||||
{t(($) => $['feedback.content'], { ns: 'common' }) || 'Feedback Content'}
|
||||
</label>
|
||||
<Textarea
|
||||
id={feedbackTextareaId}
|
||||
name="feedback-content"
|
||||
value={feedbackContent}
|
||||
onValueChange={(value) => setFeedbackContent(value)}
|
||||
placeholder={
|
||||
t(($) => $['feedback.placeholder'], { ns: 'common' }) ||
|
||||
'Please describe what went wrong or how we can improve…'
|
||||
}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end p-6 pt-5">
|
||||
<Button onClick={handleFeedbackCancel}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' }) || 'Cancel'}
|
||||
</Button>
|
||||
<Button className="ml-2" variant="primary" onClick={handleFeedbackSubmit}>
|
||||
{t(($) => $['operation.submit'], { ns: 'common' }) || 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{availableLogAction && !isOpeningStatement && (
|
||||
<div className={cn('hidden', answerActiveBlockClassName)}>
|
||||
<Log logItem={item} onOpenLog={availableLogAction} />
|
||||
</div>
|
||||
</div>
|
||||
{canManageAnnotation && annotation?.id && isShowReplyModal && (
|
||||
<EditReplyModal
|
||||
isShow
|
||||
onHide={() => setIsShowReplyModal(false)}
|
||||
query={question}
|
||||
answer={content}
|
||||
onEdited={(editedQuery, editedAnswer) =>
|
||||
onAnnotationEdited?.(editedQuery, editedAnswer, index)
|
||||
}
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
annotationId={annotation.id}
|
||||
createdAt={annotation?.created_at}
|
||||
onRemove={() => onAnnotationRemoved?.(index)}
|
||||
/>
|
||||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div
|
||||
className={cn(
|
||||
'ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-xs',
|
||||
answerActiveFlexClassName,
|
||||
)}
|
||||
data-testid="operation-actions"
|
||||
>
|
||||
{config?.text_to_speech?.enabled &&
|
||||
hasPublicContent &&
|
||||
!humanInputFormDataList?.length && (
|
||||
<NewAudioButton id={id} value={content} voice={config?.text_to_speech?.voice} />
|
||||
)}
|
||||
{hasPublicContent && !humanInputFormDataList?.length && (
|
||||
<IconButton
|
||||
aria-label={copyLabel}
|
||||
onClick={() => {
|
||||
copy(content)
|
||||
toast.success(t(($) => $['actionMsg.copySuccessfully'], { ns: 'common' }))
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-clipboard-line size-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
{(!noChatInput || showRegenerate) && (
|
||||
<IconButton aria-label={regenerateLabel} onClick={() => onRegenerate?.(item)}>
|
||||
<span aria-hidden="true" className="i-ri-reset-left-line size-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
{shouldShowAnnotationAction && (
|
||||
<AnnotationCtrlButton
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
cached={!!annotation?.id}
|
||||
query={question}
|
||||
answer={content}
|
||||
onAdded={(id, authorName) =>
|
||||
onAnnotationAdded?.(id, authorName, question, content, index)
|
||||
}
|
||||
onEdit={() => setIsShowReplyModal(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canManageAnnotation && annotation?.id && isShowReplyModal && (
|
||||
<EditReplyModal
|
||||
isShow
|
||||
onHide={() => setIsShowReplyModal(false)}
|
||||
query={question}
|
||||
answer={content}
|
||||
onEdited={(editedQuery, editedAnswer) =>
|
||||
onAnnotationEdited?.(editedQuery, editedAnswer, index)
|
||||
}
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
annotationId={annotation.id}
|
||||
createdAt={annotation?.created_at}
|
||||
onRemove={() => onAnnotationRemoved?.(index)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Operation)
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ export type FeedbackType = {
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export type FeedbackFunc = (messageId: string, feedback: FeedbackType) => Promise<any>
|
||||
export type FeedbackFunc = (messageId: string, feedback: FeedbackType) => Promise<void | boolean>
|
||||
export type SubmitAnnotationFunc = (messageId: string, content: string) => Promise<any>
|
||||
|
||||
export type ToolInfoInThought = {
|
||||
|
||||
@@ -78,4 +78,4 @@ export type Feedback = {
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export type OnFeedback = (messageId: string, feedback: Feedback) => Promise<void>
|
||||
export type OnFeedback = (messageId: string, feedback: Feedback) => Promise<void | boolean>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/features/types'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { CompletionParams } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FollowUpSettingModal from '../follow-up-setting-modal'
|
||||
import { FollowUpSettingsDialog } from '../follow-up-setting-modal'
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
@@ -52,15 +52,14 @@ vi.mock(
|
||||
}),
|
||||
)
|
||||
|
||||
const renderModal = (data: SuggestedQuestionsAfterAnswer = { enabled: true }) => {
|
||||
const renderModal = async (data: SuggestedQuestionsAfterAnswer = { enabled: true }) => {
|
||||
const onSave = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(<FollowUpSettingModal data={data} onSave={onSave} onCancel={onCancel} />)
|
||||
render(<FollowUpSettingsDialog data={data} onSave={onSave} />)
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.settings' }))
|
||||
|
||||
return {
|
||||
onSave,
|
||||
onCancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +72,7 @@ describe('FollowUpSettingModal', () => {
|
||||
describe('Model Parameters', () => {
|
||||
it('should keep max tokens disabled after its switch is turned off', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal({
|
||||
await renderModal({
|
||||
enabled: true,
|
||||
model: {
|
||||
provider: 'openai',
|
||||
@@ -97,8 +96,8 @@ describe('FollowUpSettingModal', () => {
|
||||
expect(maxTokensSwitch).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should keep max tokens disabled when saved model parameters omit it', () => {
|
||||
renderModal({
|
||||
it('should keep max tokens disabled when saved model parameters omit it', async () => {
|
||||
await renderModal({
|
||||
enabled: true,
|
||||
model: {
|
||||
provider: 'openai',
|
||||
@@ -120,7 +119,7 @@ describe('FollowUpSettingModal', () => {
|
||||
describe('Default Prompt', () => {
|
||||
it('should show the system default prompt and save without a custom prompt when no custom prompt is configured', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = renderModal()
|
||||
const { onSave } = await renderModal()
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -148,42 +147,34 @@ describe('FollowUpSettingModal', () => {
|
||||
})
|
||||
|
||||
describe('Custom Prompt', () => {
|
||||
it('should expose the selected prompt mode through RadioItem state attributes', async () => {
|
||||
it('should expose the selected prompt mode to assistive technology', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
await renderModal()
|
||||
|
||||
const defaultOption = screen
|
||||
.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption')
|
||||
.closest('button')!
|
||||
const customOption = screen
|
||||
.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption')
|
||||
.closest('button')!
|
||||
const defaultOption = screen.getByRole('radio', { name: /modal.defaultPromptOption/ })
|
||||
const customOption = screen.getByRole('radio', { name: /modal.customPromptOption/ })
|
||||
|
||||
expect(defaultOption).toHaveAttribute('data-checked')
|
||||
expect(customOption).not.toHaveAttribute('data-checked')
|
||||
expect(defaultOption).toHaveAttribute('aria-checked', 'true')
|
||||
expect(customOption).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await user.click(customOption)
|
||||
|
||||
expect(defaultOption).not.toHaveAttribute('data-checked')
|
||||
expect(customOption).toHaveAttribute('data-checked')
|
||||
expect(defaultOption).toHaveAttribute('aria-checked', 'false')
|
||||
expect(customOption).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should enable custom prompt input and save the custom prompt when selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = renderModal()
|
||||
const { onSave } = await renderModal()
|
||||
|
||||
await user.click(
|
||||
screen
|
||||
.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption')
|
||||
.closest('button')!,
|
||||
)
|
||||
await user.click(screen.getByRole('radio', { name: /modal.customPromptOption/ }))
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'appDebug.feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder',
|
||||
)
|
||||
expect(textarea).toHaveAttribute('maxLength', '1000')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'Use a custom follow-up prompt.' } })
|
||||
await user.type(textarea, 'Use a custom follow-up prompt.')
|
||||
|
||||
await user.click(screen.getByText(/common\.operation\.save/))
|
||||
|
||||
@@ -196,13 +187,9 @@ describe('FollowUpSettingModal', () => {
|
||||
|
||||
it('should disable save when custom prompt is selected but empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
await renderModal()
|
||||
|
||||
await user.click(
|
||||
screen
|
||||
.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption')
|
||||
.closest('button')!,
|
||||
)
|
||||
await user.click(screen.getByRole('radio', { name: /modal.customPromptOption/ }))
|
||||
|
||||
expect(screen.getByText(/common\.operation\.save/).closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import { FollowUp } from '../follow-up'
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ defaultModel: undefined }),
|
||||
}))
|
||||
vi.mock(
|
||||
'@/app/components/header/account-setting/model-provider-page/model-parameter-modal',
|
||||
() => ({
|
||||
default: () => <div>Model selector</div>,
|
||||
}),
|
||||
)
|
||||
|
||||
describe('Follow-up settings keyboard access', () => {
|
||||
it('reveals the persistent trigger with keyboard focus and restores visible focus after closing', async () => {
|
||||
// Native focus and CSS visibility prove the card remains usable without hovering it.
|
||||
const screen = await render(
|
||||
<FeaturesProvider features={{ suggested: { enabled: true } }}>
|
||||
<Button>Before feature</Button>
|
||||
<FollowUp />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
await screen.getByRole('button', { name: 'Before feature' }).click()
|
||||
await userEvent.tab()
|
||||
await expect.element(screen.getByRole('switch')).toHaveFocus()
|
||||
await userEvent.tab()
|
||||
const trigger = screen.getByRole('button', { name: 'common.operation.settings' })
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
|
||||
await userEvent.keyboard('{Enter}')
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await expect.element(dialog).toBeVisible()
|
||||
await dialog.getByRole('radio', { name: /modal.customPromptOption/ }).click()
|
||||
const prompt = dialog.getByRole('textbox', { name: /modal.customPromptOption/ })
|
||||
await prompt.fill('Next question')
|
||||
await userEvent.keyboard('{ArrowLeft}{ArrowUp}')
|
||||
await expect.element(prompt).toHaveFocus()
|
||||
await expect
|
||||
.element(dialog.getByRole('radio', { name: /modal.customPromptOption/ }))
|
||||
.toBeChecked()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect
|
||||
.element(dialog.getByRole('radio', { name: /modal.defaultPromptOption/ }))
|
||||
.toBeChecked()
|
||||
await dialog.getByRole('button', { name: 'common.operation.save' }).click()
|
||||
await expect.element(dialog).not.toBeInTheDocument()
|
||||
await expect.element(trigger).toHaveFocus()
|
||||
expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user