Compare commits

...

31 Commits

Author SHA1 Message Date
yyh
5258ca8814 refactor(web): preserve plugin reference dialog lifecycles 2026-10-07 12:30:32 +08:00
yyh
6aafcf2f66 refactor(web): simplify Web App menu dialogs 2026-10-07 12:30:32 +08:00
yyh
763d4a3070 fix(web): preserve first-use DSL dialog activation 2026-10-07 12:30:31 +08:00
yyh
303f6c5a5c refactor(web): scope workflow import dialogs to popup sessions 2026-10-07 12:30:31 +08:00
yyh
7bc72e53e2 test: await gallery focus restoration after Tab 2026-10-07 12:30:31 +08:00
yyh
16b9586abc fix(web): preserve gallery preload and popup resource lifetime 2026-10-07 12:30:31 +08:00
yyh
82cec802fe fix(web): keep thumbnail file type private 2026-10-07 12:30:31 +08:00
yyh
01a9274491 refactor(web): keep image triggers inside their dialog root 2026-10-07 12:30:31 +08:00
yyh
c1ae342517 refactor(web): own dataset image preview sessions 2026-10-07 12:30:31 +08:00
yyh
270f41a5b1 refactor(web): own parameter schema preview dialogs 2026-10-07 12:30:31 +08:00
yyh
5a36b938d1 refactor(web): own retrieval detail preview sessions 2026-10-07 12:30:31 +08:00
yyh
e0f132dc8a fix(web): close settings after successful site update 2026-10-07 12:30:30 +08:00
yyh
5ae6e037b5 chore(web): remove unused settings notification copy 2026-10-07 12:30:30 +08:00
yyh
ae83debcd4 refactor(web): own website settings dialog sessions 2026-10-07 12:30:30 +08:00
yyh
f3db46943a test(web): verify confirmed tool icon payloads 2026-10-07 12:30:30 +08:00
yyh
dcb40f809a fix(web): keep icon picker dismissal available in every tab 2026-10-07 12:30:30 +08:00
yyh
cc3e3ff4ae refactor(web): rely on keyboard scopes instead of picker open observers 2026-10-07 12:30:30 +08:00
yyh
3b6532c26b refactor(web): colocate icon picker entries and editing sessions 2026-10-07 12:30:30 +08:00
yyh
2cd1d49652 refactor(web): scope website embedding previews to their dialog 2026-10-07 12:30:30 +08:00
yyh
b5fa1120d8 refactor(web): own attachment preview resources in dialog sessions 2026-10-07 12:30:30 +08:00
yyh
9db88d7be0 refactor(web): scope chat feedback drafts and submission results 2026-10-07 12:30:29 +08:00
yyh
bc03de3879 refactor(web): keep workflow editor drafts inside dialog popups 2026-10-07 12:30:29 +08:00
yyh
aeabc62704 refactor(web): scope auto-update settings drafts to the popup 2026-10-07 12:30:29 +08:00
yyh
8731e9531e refactor(web): keep invitation requests in the dialog owner 2026-10-07 12:30:29 +08:00
yyh
a439622bde refactor(web): remove obsolete profile update service 2026-10-07 12:30:29 +08:00
yyh
5af2696fce refactor(web): scope account editing sessions to dialog popups 2026-10-07 12:30:29 +08:00
yyh
b40af0240d test(web): exercise the real parameter dialog from the node panel 2026-10-07 12:30:28 +08:00
yyh
bf0e797c89 refactor(web): scope configuration drafts to dialog popups 2026-10-07 12:30:28 +08:00
yyh
c6c5039f23 refactor(web): keep the customize dialog as a complete feature scope 2026-10-07 12:30:28 +08:00
yyh
eb987d0c81 refactor(web): let local preview dialogs own their open state 2026-10-07 12:30:28 +08:00
yyh
37c550095c feat(ui): expose imperative actions types for public ref contracts 2026-10-07 12:30:14 +08:00
370 changed files with 12932 additions and 9527 deletions

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import type { DataSet } from '@/models/datasets'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,11 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.g
import type { PublishedWorkflow } from '../shared/utils'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -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>
</>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,11 @@ import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,11 @@ import type { InstallBundleCompleteCallback } from '@/app/components/plugins/ins
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}
/>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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