refactor(web): drop headless-ui, migrate overlay to dify-ui (#35963)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Coding On Star 2026-05-09 18:33:25 +08:00 committed by GitHub
parent f720a3bed2
commit 8581a68174
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
187 changed files with 5333 additions and 6395 deletions

View File

@ -159,21 +159,11 @@
"count": 5
}
},
"web/app/account/(commonLayout)/delete-account/components/feed-back.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/account/(commonLayout)/delete-account/components/verify-email.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/account/(commonLayout)/delete-account/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/account/oauth/authorize/layout.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -211,9 +201,6 @@
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
@ -272,16 +259,16 @@
"count": 1
}
},
"web/app/components/app/app-access-control/add-member-or-group-pop.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/app-publisher/features-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 4
}
},
"web/app/components/app/app-publisher/version-info-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/base/var-highlight/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
@ -293,9 +280,6 @@
}
},
"web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -311,9 +295,6 @@
}
},
"web/app/components/app/configuration/config-var/config-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 4
}
@ -356,9 +337,6 @@
}
},
"web/app/components/app/configuration/config/automatic/get-automatic-res.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 4
},
@ -387,9 +365,6 @@
}
},
"web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 4
},
@ -418,9 +393,6 @@
}
},
"web/app/components/app/configuration/dataset-config/params-config/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
}
@ -494,26 +466,10 @@
"count": 1
}
},
"web/app/components/app/create-app-modal/index.tsx": {
"react/set-state-in-effect": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/create-from-dsl-modal/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
@ -521,11 +477,6 @@
"count": 2
}
},
"web/app/components/app/duplicate-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/log/filter.tsx": {
"react-refresh/only-export-components": {
"count": 1
@ -561,9 +512,6 @@
}
},
"web/app/components/app/switch-app-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
}
@ -881,11 +829,6 @@
"count": 3
}
},
"web/app/components/base/content-dialog/index.stories.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/base/date-and-time-picker/hooks.ts": {
"react/no-unnecessary-use-prefix": {
"count": 2
@ -901,11 +844,6 @@
"count": 1
}
},
"web/app/components/base/dialog/index.stories.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/base/drawer-plus/index.stories.tsx": {
"react/component-hook-factories": {
"count": 1
@ -916,11 +854,6 @@
"count": 1
}
},
"web/app/components/base/emoji-picker/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/error-boundary/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
@ -942,11 +875,6 @@
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx": {
"ts/no-explicit-any": {
"count": 3
@ -973,9 +901,6 @@
}
},
"web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -1544,16 +1469,6 @@
"count": 1
}
},
"web/app/components/base/modal-like-wrap/index.stories.tsx": {
"no-console": {
"count": 3
}
},
"web/app/components/base/modal/index.stories.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/base/new-audio-button/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -1600,6 +1515,11 @@
"count": 4
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": {
"erasable-syntax-only/parameter-properties": {
"count": 1
@ -1685,8 +1605,8 @@
}
},
"web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx": {
"ts/no-explicit-any": {
"count": 2
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/update-block.tsx": {
@ -1840,11 +1760,6 @@
"count": 4
}
},
"web/app/components/billing/annotation-full/modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/billing/billing-page/__tests__/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 4
@ -1908,11 +1823,6 @@
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -1921,9 +1831,6 @@
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/list/template-card/details/types.ts": {
@ -1931,16 +1838,6 @@
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create/file-preview/index.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -1995,11 +1892,6 @@
"count": 1
}
},
"web/app/components/datasets/create/stop-embedding-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create/website/firecrawl/index.tsx": {
"no-console": {
"count": 1
@ -2058,11 +1950,6 @@
"count": 4
}
},
"web/app/components/datasets/documents/components/rename-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -2133,11 +2020,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -2212,21 +2094,21 @@
"count": 1
}
},
"web/app/components/datasets/formatted-text/flavours/edit-slice.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/datasets/formatted-text/flavours/preview-slice.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/formatted-text/flavours/type.ts": {
"ts/no-empty-object-type": {
"count": 1
}
},
"web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/hit-testing/components/result-item-external.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/hit-testing/components/score.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
@ -2245,11 +2127,6 @@
"count": 2
}
},
"web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": {
"react/set-state-in-effect": {
"count": 1
@ -2263,36 +2140,11 @@
"count": 2
}
},
"web/app/components/datasets/metadata/metadata-dataset/create-content.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/datasets/metadata/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
}
},
"web/app/components/datasets/rename-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/settings/chunk-structure/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -2311,16 +2163,6 @@
"count": 2
}
},
"web/app/components/develop/secret-key/secret-key-generate.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/develop/secret-key/secret-key-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/explore/banner/banner-item.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -2342,11 +2184,6 @@
"count": 1
}
},
"web/app/components/explore/try-app/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/explore/try-app/tab.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -2426,16 +2263,6 @@
"count": 1
}
},
"web/app/components/header/account-about/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/api-based-extension-page/modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/data-source-page-new/card.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -2505,6 +2332,11 @@
"count": 4
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/model-auth/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 6
@ -2576,9 +2408,6 @@
}
},
"web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
},
@ -2620,9 +2449,6 @@
"erasable-syntax-only/enums": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
@ -2638,9 +2464,6 @@
}
},
"web/app/components/plugins/install-plugin/install-from-github/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -2650,21 +2473,6 @@
"count": 1
}
},
"web/app/components/plugins/install-plugin/install-from-local-package/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/marketplace/hooks.ts": {
"@tanstack/query/exhaustive-deps": {
"count": 1
@ -2675,6 +2483,11 @@
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorized/item.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2828,11 +2641,21 @@
"count": 7
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
"no-restricted-imports": {
"count": 1
@ -2846,11 +2669,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-mutation-model/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-page/context.ts": {
"ts/no-explicit-any": {
"count": 1
@ -2866,21 +2684,11 @@
"count": 2
}
},
"web/app/components/plugins/plugin-page/plugin-info.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
}
},
"web/app/components/plugins/reference-setting-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/types.ts": {
"erasable-syntax-only/enums": {
"count": 7
@ -2963,11 +2771,6 @@
"count": 4
}
},
"web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/rag-pipeline/components/rag-pipeline-children.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -2983,16 +2786,6 @@
"count": 2
}
},
"web/app/components/rag-pipeline/components/update-dsl-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/rag-pipeline/components/version-mismatch-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/rag-pipeline/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 9
@ -3048,11 +2841,6 @@
"count": 1
}
},
"web/app/components/share/text-generation/info-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/share/text-generation/menu-dropdown.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -3130,24 +2918,11 @@
"count": 1
}
},
"web/app/components/tools/mcp/mcp-server-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
}
},
"web/app/components/tools/mcp/mcp-server-param-item.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/tools/mcp/modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/provider-card.tsx": {
"ts/no-explicit-any": {
"count": 3
@ -3265,6 +3040,11 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/main.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/market-place-plugin/action.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -3280,6 +3060,11 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/tool-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3843,16 +3628,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/http/components/authorization/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/http/components/curl-panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -4034,11 +3809,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx": {
"ts/no-explicit-any": {
"count": 3
@ -4159,9 +3929,6 @@
}
},
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4395,6 +4162,9 @@
}
},
"web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
}
@ -4405,6 +4175,9 @@
}
},
"web/app/components/workflow/operator/add-block.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4466,9 +4239,6 @@
}
},
"web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
@ -4496,16 +4266,6 @@
"count": 4
}
},
"web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/panel/workflow-preview.tsx": {
"ts/no-explicit-any": {
"count": 2
@ -4641,9 +4401,6 @@
}
},
"web/app/components/workflow/update-dsl-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4757,11 +4514,6 @@
"count": 1
}
},
"web/app/education-apply/expire-notice-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/education-apply/hooks.ts": {
"react/set-state-in-effect": {
"count": 5

View File

@ -63,7 +63,7 @@ export function DialogContent({
/>
<BaseDialog.Popup
className={cn(
'fixed top-1/2 left-1/2 z-1002 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'fixed top-1/2 left-1/2 z-1002 max-h-[80dvh] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'transition-[transform,scale,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
className,
)}

131
pnpm-lock.yaml generated
View File

@ -42,9 +42,6 @@ catalogs:
'@formatjs/intl-localematcher':
specifier: 0.8.6
version: 0.8.6
'@headlessui/react':
specifier: 2.2.10
version: 2.2.10
'@heroicons/react':
specifier: 2.2.0
version: 2.2.0
@ -901,9 +898,6 @@ importers:
'@formatjs/intl-localematcher':
specifier: 'catalog:'
version: 0.8.6
'@headlessui/react':
specifier: 'catalog:'
version: 2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@heroicons/react':
specifier: 'catalog:'
version: 2.2.0(react@19.2.6)
@ -2108,12 +2102,6 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.27.19':
resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==}
peerDependencies:
@ -2129,13 +2117,6 @@ packages:
'@formatjs/intl-localematcher@0.8.6':
resolution: {integrity: sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==}
'@headlessui/react@2.2.10':
resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==}
engines: {node: '>=10'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
peerDependencies:
@ -3373,43 +3354,6 @@ packages:
'@types/react':
optional: true
'@react-aria/focus@3.21.5':
resolution: {integrity: sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/interactions@3.27.1':
resolution: {integrity: sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/ssr@3.9.10':
resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==}
engines: {node: '>= 12'}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/utils@3.33.1':
resolution: {integrity: sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-stately/flags@3.1.2':
resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==}
'@react-stately/utils@3.11.0':
resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-types/shared@3.33.1':
resolution: {integrity: sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@reactflow/background@11.3.14':
resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==}
peerDependencies:
@ -3705,9 +3649,6 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/helpers@0.5.20':
resolution: {integrity: sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==}
'@t3-oss/env-core@0.13.11':
resolution: {integrity: sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ==}
peerDependencies:
@ -9410,14 +9351,6 @@ snapshots:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@floating-ui/react@0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@floating-ui/utils': 0.2.11
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
tabbable: 6.4.0
'@floating-ui/react@0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@ -9434,16 +9367,6 @@ snapshots:
dependencies:
'@formatjs/fast-memoize': 3.1.4
'@headlessui/react@2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-aria/focus': 3.21.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-aria/interactions': 3.27.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/react-virtual': 3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
use-sync-external-store: 1.6.0(react@19.2.6)
'@heroicons/react@2.2.0(react@19.2.6)':
dependencies:
react: 19.2.6
@ -10469,55 +10392,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@react-aria/focus@3.21.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@react-aria/interactions': 3.27.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-aria/utils': 3.33.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-types/shared': 3.33.1(react@19.2.6)
'@swc/helpers': 0.5.20
clsx: 2.1.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@react-aria/interactions@3.27.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@react-aria/ssr': 3.9.10(react@19.2.6)
'@react-aria/utils': 3.33.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-stately/flags': 3.1.2
'@react-types/shared': 3.33.1(react@19.2.6)
'@swc/helpers': 0.5.20
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@react-aria/ssr@3.9.10(react@19.2.6)':
dependencies:
'@swc/helpers': 0.5.20
react: 19.2.6
'@react-aria/utils@3.33.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@react-aria/ssr': 3.9.10(react@19.2.6)
'@react-stately/flags': 3.1.2
'@react-stately/utils': 3.11.0(react@19.2.6)
'@react-types/shared': 3.33.1(react@19.2.6)
'@swc/helpers': 0.5.20
clsx: 2.1.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@react-stately/flags@3.1.2':
dependencies:
'@swc/helpers': 0.5.20
'@react-stately/utils@3.11.0(react@19.2.6)':
dependencies:
'@swc/helpers': 0.5.20
react: 19.2.6
'@react-types/shared@3.33.1(react@19.2.6)':
dependencies:
react: 19.2.6
'@reactflow/background@11.3.14(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@reactflow/core': 11.11.4(@types/react@19.2.14)(immer@11.1.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@ -10888,10 +10762,6 @@ snapshots:
dependencies:
tslib: 2.8.1
'@swc/helpers@0.5.20':
dependencies:
tslib: 2.8.1
'@t3-oss/env-core@0.13.11(typescript@6.0.3)(valibot@1.3.1(typescript@6.0.3))(zod@4.4.3)':
optionalDependencies:
typescript: 6.0.3
@ -16514,7 +16384,6 @@ time:
'@eslint/js@10.0.1': '2026-02-06T22:34:56.290Z'
'@floating-ui/react@0.27.19': '2026-03-03T03:02:09.664Z'
'@formatjs/intl-localematcher@0.8.6': '2026-05-05T17:39:39.364Z'
'@headlessui/react@2.2.10': '2026-04-07T17:12:43.551Z'
'@heroicons/react@2.2.0': '2024-11-18T15:33:27.317Z'
'@hey-api/openapi-ts@0.97.1': '2026-05-04T00:37:14.271Z'
'@hono/node-server@2.0.1': '2026-04-30T08:51:26.973Z'

View File

@ -66,7 +66,6 @@ catalog:
'@eslint/js': 10.0.1
'@floating-ui/react': 0.27.19
'@formatjs/intl-localematcher': 0.8.6
'@headlessui/react': 2.2.10
'@heroicons/react': 2.2.0
'@hey-api/openapi-ts': 0.97.1
'@hono/node-server': 2.0.1

View File

@ -63,28 +63,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
}
})
// Mock headless UI Popover so it renders content without transition
vi.mock('@headlessui/react', async () => {
const actual = await vi.importActual<typeof import('@headlessui/react')>('@headlessui/react')
return {
...actual,
Popover: ({ children, className }: { children: ((bag: { open: boolean }) => React.ReactNode) | React.ReactNode, className?: string }) => (
<div className={className} data-testid="popover-wrapper">
{typeof children === 'function' ? children({ open: true }) : children}
</div>
),
PopoverButton: ({ children, className, ref: _ref, ...rest }: Record<string, unknown>) => (
<button className={className as string} {...rest}>{children as React.ReactNode}</button>
),
PopoverPanel: ({ children, className }: { children: ((bag: { close: () => void }) => React.ReactNode) | React.ReactNode, className?: string }) => (
<div className={className}>
{typeof children === 'function' ? children({ close: vi.fn() }) : children}
</div>
),
Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
vi.mock('@/next/dynamic', () => ({
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
let Component: React.ComponentType<Record<string, unknown>> | null = null

View File

@ -13,7 +13,7 @@ import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ApiServer from '@/app/components/develop/ApiServer'
// ---------- fake timers (HeadlessUI Dialog transitions) ----------
// ---------- fake timers (modal transitions) ----------
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
@ -100,7 +100,7 @@ describe('API Key management flow', () => {
})
await flushUI()
// SecretKeyModal should render with real HeadlessUI Dialog
// SecretKeyModal should render with real modal content
await waitFor(() => {
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()

View File

@ -131,6 +131,7 @@ describe('Header Account Dropdown Flow', () => {
payload: ACCOUNT_SETTING_TAB.MEMBERS,
})
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
fireEvent.click(screen.getByText('common.userProfile.about'))
await waitFor(() => {

View File

@ -13,83 +13,6 @@ const mockOnLoadMore = vi.fn()
let mockSelectedSegment = 'app'
let mockIsCurrentWorkspaceEditor = true
vi.mock('@headlessui/react', () => {
type MenuContextValue = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
}
const MenuContext = React.createContext<MenuContextValue | null>(null)
const Menu = ({
children,
}: {
children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode)
}) => {
const [open, setOpen] = React.useState(false)
const value = React.useMemo(() => ({ open, setOpen }), [open])
return (
<MenuContext.Provider value={value}>
{typeof children === 'function' ? children({ open }) : children}
</MenuContext.Provider>
)
}
const MenuButton = ({
children,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const context = React.useContext(MenuContext)
return (
<button
type="button"
aria-expanded={context?.open ?? false}
onClick={(event) => {
context?.setOpen(v => !v)
onClick?.(event)
}}
{...props}
>
{children}
</button>
)
}
const MenuItems = ({
as: Component = 'div',
children,
...props
}: {
as?: React.ElementType
children: React.ReactNode
} & Record<string, unknown>) => {
const context = React.useContext(MenuContext)
if (!context?.open)
return null
return <Component {...props}>{children}</Component>
}
const MenuItem = ({
as: Component = 'div',
children,
...props
}: {
as?: React.ElementType
children: React.ReactNode
} & Record<string, unknown>) => <Component {...props}>{children}</Component>
return {
Menu,
MenuButton,
MenuItems,
MenuItem,
Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,

View File

@ -19,6 +19,8 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppSideBar from '@/app/components/app-sidebar'
import { AppInfoDetailLayer } from '@/app/components/app-sidebar/app-info'
import { useAppInfoActions } from '@/app/components/app-sidebar/app-info/use-app-info-actions'
import { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
@ -45,6 +47,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const appInfoActions = useAppInfoActions({ resetKey: appId })
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
setAppDetail: state.setAppDetail,
@ -162,11 +165,13 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
{appDetail && (
<AppSideBar
navigation={navigation}
appInfoActions={appInfoActions}
/>
)}
<div className="grow overflow-hidden bg-components-panel-bg">
{children}
</div>
<AppInfoDetailLayer actions={appInfoActions} />
</div>
)
}

View File

@ -1,9 +1,9 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CustomDialog from '@/app/components/base/dialog'
import Textarea from '@/app/components/base/textarea'
import { useAppContext } from '@/context/app-context'
import { useRouter } from '@/next/navigation'
@ -47,26 +47,34 @@ export default function FeedBack(props: DeleteAccountProps) {
handleSuccess()
}, [handleSuccess, props])
return (
<CustomDialog
show={true}
onClose={props.onCancel}
title={t('account.feedbackTitle', { ns: 'common' })}
className="max-w-[480px]"
footer={false}
<Dialog
open
onOpenChange={(open) => {
if (!open)
props.onCancel()
}}
>
<label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label>
<Textarea
rows={6}
value={userFeedback}
placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string}
onChange={(e) => {
setUserFeedback(e.target.value)
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">
<Button className="w-full" loading={isPending} variant="primary" onClick={handleSubmit}>{t('operation.submit', { ns: 'common' })}</Button>
<Button className="w-full" onClick={handleSkip}>{t('operation.skip', { ns: 'common' })}</Button>
</div>
</CustomDialog>
<DialogContent
className="max-w-[480px] overflow-hidden!"
backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]"
>
<DialogTitle className="pr-8 pb-3 title-2xl-semi-bold text-text-primary">
{t('account.feedbackTitle', { ns: 'common' })}
</DialogTitle>
<label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label>
<Textarea
rows={6}
value={userFeedback}
placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string}
onChange={(e) => {
setUserFeedback(e.target.value)
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">
<Button className="w-full" loading={isPending} variant="primary" onClick={handleSubmit}>{t('operation.submit', { ns: 'common' })}</Button>
<Button className="w-full" onClick={handleSkip}>{t('operation.skip', { ns: 'common' })}</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,7 +1,7 @@
'use client'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CustomDialog from '@/app/components/base/dialog'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import CheckEmail from './components/check-email'
import FeedBack from './components/feed-back'
@ -30,22 +30,30 @@ export default function DeleteAccount(props: DeleteAccountProps) {
return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} />
return (
<CustomDialog
show={true}
onClose={props.onCancel}
title={t('account.delete', { ns: 'common' })}
className="max-w-[480px]"
footer={false}
<Dialog
open
onOpenChange={(open) => {
if (!open)
props.onCancel()
}}
>
{!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
{showVerifyEmail && (
<VerifyEmail
onCancel={props.onCancel}
onConfirm={() => {
setShowFeedbackDialog(true)
}}
/>
)}
</CustomDialog>
<DialogContent
className="max-w-[480px] overflow-hidden!"
backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]"
>
<DialogTitle className="pr-8 pb-3 title-2xl-semi-bold text-text-primary">
{t('account.delete', { ns: 'common' })}
</DialogTitle>
{!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
{showVerifyEmail && (
<VerifyEmail
onCancel={props.onCancel}
onConfirm={() => {
setShowFeedbackDialog(true)
}}
/>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -60,6 +60,9 @@ vi.mock('../app-info', () => ({
default: ({ expand }: { expand: boolean }) => (
<div data-testid="app-info" data-expand={expand} />
),
AppInfoView: ({ expand }: { expand: boolean }) => (
<div data-testid="app-info" data-expand={expand} />
),
}))
vi.mock('../app-sidebar-dropdown', () => ({

View File

@ -11,17 +11,16 @@ vi.mock('../../../base/app-icon', () => ({
),
}))
vi.mock('@/app/components/base/content-dialog', () => ({
default: ({ show, onClose, children, className }: {
show: boolean
vi.mock('../app-info-detail-drawer', () => ({
AppInfoDetailDrawer: ({ open, onClose, children }: {
open: boolean
onClose: () => void
children: React.ReactNode
className?: string
}) => (
show
open
? (
<div data-testid="content-dialog" className={className}>
<button type="button" data-testid="dialog-close" onClick={onClose}>Close</button>
<div data-testid="app-info-detail-drawer">
<button type="button" data-testid="drawer-close" onClick={onClose}>Close</button>
{children}
</div>
)
@ -96,12 +95,12 @@ describe('AppInfoDetailPanel', () => {
describe('Rendering', () => {
it('should not render when show is false', () => {
render(<AppInfoDetailPanel {...defaultProps} show={false} />)
expect(screen.queryByTestId('content-dialog')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-info-detail-drawer')).not.toBeInTheDocument()
})
it('should render dialog when show is true', () => {
it('should render drawer when show is true', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('content-dialog')).toBeInTheDocument()
expect(screen.getByTestId('app-info-detail-drawer')).toBeInTheDocument()
})
it('should display app name', () => {
@ -285,12 +284,12 @@ describe('AppInfoDetailPanel', () => {
})
})
describe('Dialog interactions', () => {
it('should call onClose when dialog close button is clicked', async () => {
describe('Drawer interactions', () => {
it('should call onClose when drawer close button is clicked', async () => {
const user = userEvent.setup()
render(<AppInfoDetailPanel {...defaultProps} />)
await user.click(screen.getByTestId('dialog-close'))
await user.click(screen.getByTestId('drawer-close'))
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})

View File

@ -153,6 +153,27 @@ describe('useAppInfoActions', () => {
expect(result.current.panelOpen).toBe(false)
expect(onDetailExpand).toHaveBeenCalledWith(false)
})
it('should reset app-scoped state when resetKey changes', () => {
const { result, rerender } = renderHook(
({ resetKey }) => useAppInfoActions({ resetKey }),
{ initialProps: { resetKey: 'app-1' } },
)
act(() => {
result.current.openModal('delete')
result.current.setPanelOpen(true)
})
expect(result.current.panelOpen).toBe(true)
expect(result.current.activeModal).toBe('delete')
rerender({ resetKey: 'app-2' })
expect(result.current.panelOpen).toBe(false)
expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([])
})
})
describe('Modal management', () => {

View File

@ -0,0 +1,34 @@
import type { ReactNode } from 'react'
type AppInfoDetailDrawerProps = {
open: boolean
onClose: () => void
children: ReactNode
}
export function AppInfoDetailDrawer({
open,
onClose,
children,
}: AppInfoDetailDrawerProps) {
if (!open)
return null
return (
<div className="absolute inset-0 z-50">
<button
type="button"
aria-label="Close app info"
className="absolute inset-0 cursor-default bg-app-detail-overlay-bg"
onClick={onClose}
/>
<section
role="dialog"
aria-modal="false"
className="absolute top-2 bottom-2 left-2 flex w-[452px] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border-r border-divider-burn bg-app-detail-bg"
>
{children}
</section>
</div>
)
}

View File

@ -14,9 +14,9 @@ import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import ContentDialog from '@/app/components/base/content-dialog'
import { AppModeEnum } from '@/types/app'
import AppIcon from '../../base/app-icon'
import { AppInfoDetailDrawer } from './app-info-detail-drawer'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
@ -94,10 +94,9 @@ const AppInfoDetailPanel = ({
}, [appDetail.mode, t, openModal])
return (
<ContentDialog
show={show}
<AppInfoDetailDrawer
open={show}
onClose={onClose}
className="absolute top-2 bottom-2 left-2 flex w-[452px] max-w-[calc(100vw-1rem)] flex-col rounded-2xl p-0!"
>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
@ -109,16 +108,16 @@ const AppInfoDetailPanel = ({
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<div className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</div>
<h2 className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</h2>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
</div>
{appDetail.description && (
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
<p className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
{appDetail.description}
</div>
</p>
)}
<AppOperations
gap={4}
@ -144,7 +143,7 @@ const AppInfoDetailPanel = ({
</Button>
</div>
)}
</ContentDialog>
</AppInfoDetailDrawer>
)
}

View File

@ -1,3 +1,4 @@
import type { AppInfoActions } from './use-app-info-actions'
import * as React from 'react'
import { useAppContext } from '@/context/app-context'
import AppInfoDetailPanel from './app-info-detail-panel'
@ -12,13 +13,22 @@ type IAppInfoProps = {
onDetailExpand?: (expand: boolean) => void
}
const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => {
const { isCurrentWorkspaceEditor } = useAppContext()
type AppInfoViewProps = Omit<IAppInfoProps, 'onDetailExpand'> & {
actions: AppInfoActions
renderDetail?: boolean
}
type AppInfoDetailLayerProps = {
actions: AppInfoActions
open?: boolean
}
export const AppInfoDetailLayer = ({
actions,
open = actions.panelOpen,
}: AppInfoDetailLayerProps) => {
const {
appDetail,
panelOpen,
setPanelOpen,
closePanel,
activeModal,
openModal,
@ -31,26 +41,16 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
exportCheck,
handleConfirmExport,
onConfirmDelete,
} = useAppInfoActions({ onDetailExpand })
} = actions
if (!appDetail)
return null
return (
<div>
{!onlyShowDetail && (
<AppInfoTrigger
appDetail={appDetail}
expand={expand}
onClick={() => {
if (isCurrentWorkspaceEditor)
setPanelOpen(v => !v)
}}
/>
)}
<>
<AppInfoDetailPanel
appDetail={appDetail}
show={onlyShowDetail ? openState : panelOpen}
show={open}
onClose={closePanel}
openModal={openModal}
exportCheck={exportCheck}
@ -68,8 +68,58 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
handleConfirmExport={handleConfirmExport}
onConfirmDelete={onConfirmDelete}
/>
</>
)
}
export const AppInfoView = ({
expand,
onlyShowDetail = false,
openState = false,
actions,
renderDetail = true,
}: AppInfoViewProps) => {
const { isCurrentWorkspaceEditor } = useAppContext()
const {
appDetail,
panelOpen,
setPanelOpen,
} = actions
if (!appDetail)
return null
return (
<div>
{!onlyShowDetail && (
<AppInfoTrigger
appDetail={appDetail}
expand={expand}
onClick={() => {
if (isCurrentWorkspaceEditor)
setPanelOpen(v => !v)
}}
/>
)}
{renderDetail && (
<AppInfoDetailLayer
actions={actions}
open={onlyShowDetail ? openState : panelOpen}
/>
)}
</div>
)
}
const AppInfo = ({ onDetailExpand, ...props }: IAppInfoProps) => {
const actions = useAppInfoActions({ onDetailExpand })
return (
<AppInfoView
{...props}
actions={actions}
/>
)
}
export default React.memo(AppInfo)

View File

@ -1,3 +1,4 @@
import type { Dispatch, SetStateAction } from 'react'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
@ -19,9 +20,36 @@ export type AppInfoModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'imp
type UseAppInfoActionsParams = {
onDetailExpand?: (expand: boolean) => void
resetKey?: string
}
export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
type AppInfoUiState = {
resetKey?: string
panelOpen: boolean
activeModal: AppInfoModalType
secretEnvList: EnvironmentVariable[]
}
const emptySecretEnvList: EnvironmentVariable[] = []
const createInitialUiState = (resetKey?: string): AppInfoUiState => ({
resetKey,
panelOpen: false,
activeModal: null,
secretEnvList: [],
})
const resolveStateAction = <T>(value: SetStateAction<T>, previous: T) => {
return typeof value === 'function'
? (value as (previous: T) => T)(previous)
: value
}
const getCurrentUiState = (state: AppInfoUiState, resetKey?: string) => {
return state.resetKey === resetKey ? state : createInitialUiState(resetKey)
}
export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoActionsParams) {
const { t } = useTranslation()
const { replace } = useRouter()
const { onPlanInfoChanged } = useProviderContext()
@ -29,23 +57,55 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
const setAppDetail = useAppStore(state => state.setAppDetail)
const invalidateAppList = useInvalidateAppList()
const [panelOpen, setPanelOpen] = useState(false)
const [activeModal, setActiveModal] = useState<AppInfoModalType>(null)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [uiState, setUiState] = useState(() => createInitialUiState(resetKey))
const uiStateMatchesResetKey = uiState.resetKey === resetKey
const panelOpen = uiStateMatchesResetKey ? uiState.panelOpen : false
const activeModal = uiStateMatchesResetKey ? uiState.activeModal : null
const secretEnvList = uiStateMatchesResetKey ? uiState.secretEnvList : emptySecretEnvList
const setPanelOpen = useCallback<Dispatch<SetStateAction<boolean>>>((value) => {
setUiState((state) => {
const current = getCurrentUiState(state, resetKey)
return {
...current,
panelOpen: resolveStateAction(value, current.panelOpen),
}
})
}, [resetKey])
const setActiveModal = useCallback<Dispatch<SetStateAction<AppInfoModalType>>>((value) => {
setUiState((state) => {
const current = getCurrentUiState(state, resetKey)
return {
...current,
activeModal: resolveStateAction(value, current.activeModal),
}
})
}, [resetKey])
const setSecretEnvList = useCallback<Dispatch<SetStateAction<EnvironmentVariable[]>>>((value) => {
setUiState((state) => {
const current = getCurrentUiState(state, resetKey)
return {
...current,
secretEnvList: resolveStateAction(value, current.secretEnvList),
}
})
}, [resetKey])
const closePanel = useCallback(() => {
setPanelOpen(false)
onDetailExpand?.(false)
}, [onDetailExpand])
}, [onDetailExpand, setPanelOpen])
const openModal = useCallback((modal: Exclude<AppInfoModalType, null>) => {
closePanel()
setActiveModal(modal)
}, [closePanel])
}, [closePanel, setActiveModal])
const closeModal = useCallback(() => {
setActiveModal(null)
}, [])
}, [setActiveModal])
const emitAppMetaUpdate = useCallback(() => {
if (!appDetail?.id)
@ -178,7 +238,7 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
return
}
setActiveModal('exportWarning')
}, [appDetail, onExport])
}, [appDetail, onExport, setActiveModal])
const handleConfirmExport = useCallback(async () => {
if (!appDetail)
@ -198,7 +258,7 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
finally {
closeModal()
}
}, [appDetail, closeModal, onExport, t])
}, [appDetail, closeModal, onExport, setSecretEnvList, t])
const onConfirmDelete = useCallback(async () => {
if (!appDetail)
@ -235,3 +295,5 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
onConfirmDelete,
}
}
export type AppInfoActions = ReturnType<typeof useAppInfoActions>

View File

@ -1,3 +1,4 @@
import type { AppInfoActions } from './app-info/use-app-info-actions'
import type { NavIcon } from './nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import {
@ -27,9 +28,10 @@ type Props = {
icon: NavIcon
selectedIcon: NavIcon
}>
appInfoActions?: AppInfoActions
}
const AppSidebarDropdown = ({ navigation }: Props) => {
const AppSidebarDropdown = ({ navigation, appInfoActions }: Props) => {
const { t } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
@ -69,7 +71,10 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
<div
className={cn('flex flex-col gap-2 rounded-lg p-2 pb-2.5', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}
onClick={() => {
setDetailExpand(true)
if (appInfoActions)
appInfoActions.setPanelOpen(true)
else
setDetailExpand(true)
setOpen(false)
}}
>
@ -109,9 +114,11 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="z-20">
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />
</div>
{!appInfoActions && (
<div className="z-20">
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />
</div>
)}
</>
)
}

View File

@ -1,3 +1,4 @@
import type { AppInfoActions } from './app-info/use-app-info-actions'
import type { NavIcon } from './nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { useHover, useKeyPress } from 'ahooks'
@ -10,7 +11,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { usePathname } from '@/next/navigation'
import Divider from '../base/divider'
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
import AppInfo from './app-info'
import AppInfo, { AppInfoView } from './app-info'
import AppSidebarDropdown from './app-sidebar-dropdown'
import DatasetInfo from './dataset-info'
import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
@ -36,12 +37,14 @@ type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
appInfoActions?: AppInfoActions
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
appInfoActions,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@ -89,7 +92,10 @@ const AppDetailNav = ({
if (inWorkflowCanvas && hideHeader) {
return (
<div className="flex w-0 shrink-0">
<AppSidebarDropdown navigation={navigation} />
<AppSidebarDropdown
navigation={navigation}
appInfoActions={appInfoActions}
/>
</div>
)
}
@ -117,7 +123,15 @@ const AppDetailNav = ({
)}
>
{iconType === 'app' && (
<AppInfo expand={expand} />
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{iconType !== 'app' && (
<DatasetInfo expand={expand} />

View File

@ -1,13 +1,12 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import AnnotationFull from '@/app/components/billing/annotation-full'
import { useProviderContext } from '@/context/provider-context'
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
@ -88,37 +87,40 @@ const BatchModal: FC<IBatchModalProps> = ({
}
return (
<Modal isShow={isShow} onClose={noop} className="max-w-[520px]! rounded-xl! px-8 py-6">
<div className="relative pb-1 system-xl-medium text-text-primary">{t('batchModal.title', { ns: 'appAnnotation' })}</div>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
<CSVUploader
file={currentCSV}
updateFile={handleFile}
/>
<CSVDownloader />
<Dialog open={isShow}>
<DialogContent className="w-full max-w-[520px]! overflow-hidden! rounded-xl! border-none px-8 py-6 text-left align-middle">
{isAnnotationFull && (
<div className="mt-4">
<AnnotationFull />
<div className="relative pb-1 system-xl-medium text-text-primary">{t('batchModal.title', { ns: 'appAnnotation' })}</div>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onCancel}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
)}
<CSVUploader
file={currentCSV}
updateFile={handleFile}
/>
<CSVDownloader />
<div className="mt-[28px] flex justify-end pt-6">
<Button className="mr-2 system-sm-medium text-text-tertiary" onClick={onCancel}>
{t('batchModal.cancel', { ns: 'appAnnotation' })}
</Button>
<Button
variant="primary"
onClick={handleSend}
disabled={isAnnotationFull || !currentCSV}
loading={importStatus === ProcessStatus.PROCESSING || importStatus === ProcessStatus.WAITING}
>
{t('batchModal.run', { ns: 'appAnnotation' })}
</Button>
</div>
</Modal>
{isAnnotationFull && (
<div className="mt-4">
<AnnotationFull />
</div>
)}
<div className="mt-[28px] flex justify-end pt-6">
<Button className="mr-2 system-sm-medium text-text-tertiary" onClick={onCancel}>
{t('batchModal.cancel', { ns: 'appAnnotation' })}
</Button>
<Button
variant="primary"
onClick={handleSend}
disabled={isAnnotationFull || !currentCSV}
loading={importStatus === ProcessStatus.PROCESSING || importStatus === ProcessStatus.WAITING}
>
{t('batchModal.run', { ns: 'appAnnotation' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
export default React.memo(BatchModal)

View File

@ -5,129 +5,11 @@ import type { AnnotationItemBasic } from '../../type'
import type { Locale } from '@/i18n-config'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import HeaderOptions from '../index'
vi.mock('@headlessui/react', () => {
type PopoverContextValue = { open: boolean, setOpen: (open: boolean) => void }
type MenuContextValue = { open: boolean, setOpen: (open: boolean) => void }
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
const MenuContext = React.createContext<MenuContextValue | null>(null)
const Popover = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => {
const [open, setOpen] = React.useState(false)
const value = React.useMemo(() => ({ open, setOpen }), [open])
return (
<PopoverContext.Provider value={value}>
{typeof children === 'function' ? children({ open }) : children}
</PopoverContext.Provider>
)
}
const PopoverButton = React.forwardRef(({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }, ref: React.Ref<HTMLButtonElement>) => {
const context = React.useContext(PopoverContext)
const handleClick = () => {
context?.setOpen(!context.open)
onClick?.()
}
return (
<button
ref={ref}
type="button"
aria-expanded={context?.open ?? false}
onClick={handleClick}
{...props}
>
{children}
</button>
)
})
const PopoverPanel = React.forwardRef(({ children, ...props }: { children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode) }, ref: React.Ref<HTMLDivElement>) => {
const context = React.useContext(PopoverContext)
if (!context?.open)
return null
const content = typeof children === 'function' ? children({ close: () => context.setOpen(false) }) : children
return (
<div ref={ref} {...props}>
{content}
</div>
)
})
const Menu = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = React.useState(false)
const value = React.useMemo(() => ({ open, setOpen }), [open])
return (
<MenuContext.Provider value={value}>
{children}
</MenuContext.Provider>
)
}
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void, children?: React.ReactNode }) => {
const context = React.useContext(MenuContext)
const handleClick = () => {
context?.setOpen(!context.open)
onClick?.()
}
return (
<button type="button" aria-expanded={context?.open ?? false} onClick={handleClick} {...props}>
{children}
</button>
)
}
const MenuItems = ({ children, ...props }: { children: React.ReactNode }) => {
const context = React.useContext(MenuContext)
if (!context?.open)
return null
return (
<div {...props}>
{children}
</div>
)
}
return {
Dialog: ({ open, children, className }: { open?: boolean, children: React.ReactNode, className?: string }) => {
if (open === false)
return null
return (
<div role="dialog" className={className}>
{children}
</div>
)
},
DialogBackdrop: ({ children, className, onClick }: { children?: React.ReactNode, className?: string, onClick?: () => void }) => (
<div className={className} onClick={onClick}>
{children}
</div>
),
DialogPanel: ({ children, className, ...props }: { children: React.ReactNode, className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
DialogTitle: ({ children, className, ...props }: { children: React.ReactNode, className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
Popover,
PopoverButton,
PopoverPanel,
Menu,
MenuButton,
MenuItems,
Transition: ({ show = true, children }: { show?: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null),
TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
const mockJsonToCSV = vi.fn((_: unknown) => 'csv-content')
const mockCSVDownloader = vi.fn(({ children }) => <>{children}</>)

View File

@ -10,6 +10,7 @@ describe('AccessControlDialog', () => {
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toHaveClass('custom-dialog')
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
})

View File

@ -1,4 +1,3 @@
/* eslint-disable ts/no-explicit-any */
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
@ -43,34 +42,6 @@ vi.mock('@/service/access-control', () => ({
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
}))
vi.mock('@headlessui/react', () => {
const DialogComponent: any = ({ children, className, ...rest }: any) => (
<div role="dialog" className={className} {...rest}>{children}</div>
)
DialogComponent.Panel = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const DialogTitle = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const DialogDescription = ({ children, className, ...rest }: any) => (
<div className={className} {...rest}>{children}</div>
)
const TransitionChild = ({ children }: any) => (
<>{typeof children === 'function' ? children({}) : children}</>
)
const Transition = ({ show = true, children }: any) => (
show ? <>{typeof children === 'function' ? children({}) : children}</> : null
)
Transition.Child = TransitionChild
return {
Dialog: DialogComponent,
Transition,
DialogTitle,
Description: DialogDescription,
}
})
vi.mock('ahooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('ahooks')>()
return {

View File

@ -23,9 +23,15 @@ const AccessControlDialog = ({
const close = useCallback(() => {
onClose?.()
}, [onClose])
return (
<Dialog open={show} onOpenChange={open => !open && close()}>
<DialogContent className={cn('min-h-[323px] w-[600px] p-0', className)}>
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && close()}>
<DialogContent
className={cn(
'h-auto max-h-none min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-all',
className,
)}
>
<DialogCloseButton className="top-5 right-5 h-8 w-8" />
{children}
</DialogContent>

View File

@ -87,6 +87,22 @@ describe('VersionInfoModal', () => {
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should close when the dialog requests close', () => {
const handleClose = vi.fn()
render(
<VersionInfoModal
isOpen
onClose={handleClose}
onPublish={vi.fn()}
/>,
)
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should validate release note length and clear previous errors before publishing', () => {
const handlePublish = vi.fn()
const handleClose = vi.fn()

View File

@ -1,12 +1,12 @@
import type { FC } from 'react'
import type { VersionHistory } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Input from '../../base/input'
import Textarea from '../../base/textarea'
@ -66,46 +66,55 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
}, [])
return (
<Modal className="p-0" isShow={isOpen} onClose={onClose}>
<div className="relative w-full p-6 pr-14 pb-4">
<div className="title-2xl-semi-bold text-text-primary first-letter:capitalize">
{versionInfo?.marked_name ? t('versionHistory.editVersionInfo', { ns: 'workflow' }) : t('versionHistory.nameThisVersion', { ns: 'workflow' })}
</div>
<div className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={onClose}>
<RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" />
</div>
</div>
<div className="flex flex-col gap-y-4 px-6 py-3">
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t('versionHistory.editField.title', { ns: 'workflow' })}
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
<DialogContent className="w-full max-w-[480px] overflow-hidden! border-none p-0 text-left align-middle">
<div className="relative w-full p-6 pr-14 pb-4">
<div className="title-2xl-semi-bold text-text-primary first-letter:capitalize">
{versionInfo?.marked_name ? t('versionHistory.editVersionInfo', { ns: 'workflow' }) : t('versionHistory.nameThisVersion', { ns: 'workflow' })}
</div>
<Input
value={title}
placeholder={`${t('versionHistory.nameThisVersion', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onChange={handleTitleChange}
destructive={titleError}
/>
</div>
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t('versionHistory.editField.releaseNotes', { ns: 'workflow' })}
<div className="absolute top-5 right-5 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5" onClick={onClose}>
<RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" />
</div>
<Textarea
value={releaseNotes}
placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onChange={handleDescriptionChange}
destructive={releaseNotesError}
/>
</div>
</div>
<div className="flex justify-end p-6 pt-5">
<div className="flex items-center gap-x-3">
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" onClick={handlePublish}>{t('common.publish', { ns: 'workflow' })}</Button>
<div className="flex flex-col gap-y-4 px-6 py-3">
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t('versionHistory.editField.title', { ns: 'workflow' })}
</div>
<Input
value={title}
placeholder={`${t('versionHistory.nameThisVersion', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onChange={handleTitleChange}
destructive={titleError}
/>
</div>
<div className="flex flex-col gap-y-1">
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
{t('versionHistory.editField.releaseNotes', { ns: 'workflow' })}
</div>
<Textarea
value={releaseNotes}
placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
onChange={handleDescriptionChange}
destructive={releaseNotesError}
/>
</div>
</div>
</div>
</Modal>
<div className="flex justify-end p-6 pt-5">
<div className="flex items-center gap-x-3">
<Button nativeButton={false} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button nativeButton={false} variant="primary" onClick={handlePublish}>{t('common.publish', { ns: 'workflow' })}</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -3,8 +3,11 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import EditModal from '../edit-modal'
vi.mock('@/app/components/base/modal', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('Conversation history edit modal', () => {

View File

@ -2,10 +2,10 @@
import type { FC } from 'react'
import type { ConversationHistoriesRole } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
type Props = {
isShow: boolean
@ -25,37 +25,45 @@ const EditModal: FC<Props> = ({
const { t } = useTranslation()
const [tempData, setTempData] = useState(data)
return (
<Modal
title={t('feature.conversationHistory.editModal.title', { ns: 'appDebug' })}
isShow={isShow}
onClose={onClose}
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
<div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('feature.conversationHistory.editModal.userPrefix', { ns: 'appDebug' })}</div>
<input
className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10"
value={tempData.user_prefix}
onChange={e => setTempData({
...tempData,
user_prefix: e.target.value,
})}
/>
<DialogContent className="w-full max-w-[480px] overflow-hidden! border-none p-6 text-left align-middle">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('feature.conversationHistory.editModal.title', { ns: 'appDebug' })}
</DialogTitle>
<div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('feature.conversationHistory.editModal.assistantPrefix', { ns: 'appDebug' })}</div>
<input
className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10"
value={tempData.assistant_prefix}
onChange={e => setTempData({
...tempData,
assistant_prefix: e.target.value,
})}
placeholder={t('chat.conversationNamePlaceholder', { ns: 'common' }) || ''}
/>
<div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('feature.conversationHistory.editModal.userPrefix', { ns: 'appDebug' })}</div>
<input
className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10"
value={tempData.user_prefix}
onChange={e => setTempData({
...tempData,
user_prefix: e.target.value,
})}
/>
<div className="mt-10 flex justify-end">
<Button className="mr-2 shrink-0" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" className="shrink-0" onClick={() => onSave(tempData)} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</Modal>
<div className="mt-6 text-sm leading-[21px] font-medium text-text-primary">{t('feature.conversationHistory.editModal.assistantPrefix', { ns: 'appDebug' })}</div>
<input
className="mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10"
value={tempData.assistant_prefix}
onChange={e => setTempData({
...tempData,
assistant_prefix: e.target.value,
})}
placeholder={t('chat.conversationNamePlaceholder', { ns: 'common' }) || ''}
/>
<div className="mt-10 flex justify-end">
<Button className="mr-2 shrink-0" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" className="shrink-0" onClick={() => onSave(tempData)} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -2,13 +2,13 @@
import type { ChangeEvent, FC } from 'react'
import type { Item as SelectItem } from './type-select'
import type { InputVar, InputVarType, MoreInfo } from '@/app/components/workflow/types'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useStore as useAppStore } from '@/app/components/app/store'
import Modal from '@/app/components/base/modal'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
@ -141,35 +141,43 @@ const ConfigModal: FC<IConfigModalProps> = ({
}
return (
<Modal
title={t(`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`, { ns: 'appDebug' })}
isShow={isShow}
onClose={onClose}
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
<div className="mb-8" ref={modalRef} tabIndex={-1}>
<ConfigModalFormFields
checkboxDefaultSelectValue={checkboxDefaultSelectValue}
isStringInput={isStringInput}
jsonSchemaStr={jsonSchemaStr}
maxLength={max_length}
modelId={modelConfig.model_id}
onFilePayloadChange={payload => setTempPayload(payload as InputVar)}
onJSONSchemaChange={handleJSONSchemaChange}
onPayloadChange={handlePayloadChange}
onTypeChange={handleTypeChange}
onVarKeyBlur={handleVarKeyBlur}
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
tempPayload={tempPayload}
t={t}
<DialogContent className="overflow-hidden! border-none text-left align-middle">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`, { ns: 'appDebug' })}
</DialogTitle>
<div className="mb-8" ref={modalRef} tabIndex={-1}>
<ConfigModalFormFields
checkboxDefaultSelectValue={checkboxDefaultSelectValue}
isStringInput={isStringInput}
jsonSchemaStr={jsonSchemaStr}
maxLength={max_length}
modelId={modelConfig.model_id}
onFilePayloadChange={payload => setTempPayload(payload as InputVar)}
onJSONSchemaChange={handleJSONSchemaChange}
onPayloadChange={handlePayloadChange}
onTypeChange={handleTypeChange}
onVarKeyBlur={handleVarKeyBlur}
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
tempPayload={tempPayload}
t={t}
/>
</div>
<ModalFoot
onConfirm={handleConfirm}
onCancel={onClose}
/>
</div>
<ModalFoot
onConfirm={handleConfirm}
onCancel={onClose}
/>
</Modal>
</DialogContent>
</Dialog>
)
}
export default React.memo(ConfigModal)

View File

@ -14,6 +14,7 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiDatabase2Line,
@ -32,9 +33,8 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug'
@ -282,143 +282,148 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
}
return (
<Modal
isShow={isShow}
onClose={onClose}
className="min-w-[1140px] p-0!"
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
<div className="flex h-[680px] flex-wrap">
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('generate.title', { ns: 'appDebug' })}</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('generate.description', { ns: 'appDebug' })}</div>
</div>
<div>
<ModelParameterModal
popupClassName="w-[520px]!"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
{isBasicMode && (
<div className="mt-4">
<div className="flex items-center">
<div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary uppercase">{t('generate.tryIt', { ns: 'appDebug' })}</div>
<div
className="h-px grow"
style={{
background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))',
}}
>
<DialogContent className="max-h-none w-[1140px] max-w-none! min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle">
<div className="flex h-[680px] flex-wrap">
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('generate.title', { ns: 'appDebug' })}</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('generate.description', { ns: 'appDebug' })}</div>
</div>
<div>
<ModelParameterModal
popupClassName="w-[520px]!"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
{isBasicMode && (
<div className="mt-4">
<div className="flex items-center">
<div className="mr-3 shrink-0 text-xs leading-[18px] font-semibold text-text-tertiary uppercase">{t('generate.tryIt', { ns: 'appDebug' })}</div>
<div
className="h-px grow"
style={{
background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))',
}}
>
</div>
</div>
<div className="flex flex-wrap">
{tryList.map(item => (
<TryLabel
key={item.key}
Icon={item.icon}
text={t(`generate.template.${item.key}.name`, { ns: 'appDebug' })}
onClick={handleChooseTemplate(item.key)}
/>
))}
</div>
</div>
<div className="flex flex-wrap">
{tryList.map(item => (
<TryLabel
key={item.key}
Icon={item.icon}
text={t(`generate.template.${item.key}.name`, { ns: 'appDebug' })}
onClick={handleChooseTemplate(item.key)}
/>
))}
)}
{/* inputs */}
<div className="mt-4">
<div>
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div>
{isBasicMode
? (
<InstructionEditorInBasic
editorKey={editorKey}
generatorType={GeneratorType.prompt}
value={instruction}
onChange={setInstruction}
availableVars={[]}
availableNodes={[]}
isShowCurrentBlock={!!currentPrompt}
isShowLastRunBlock={false}
/>
)
: (
<InstructionEditorInWorkflow
editorKey={editorKey}
generatorType={GeneratorType.prompt}
value={instruction}
onChange={setInstruction}
nodeId={nodeId || ''}
isShowCurrentBlock={!!currentPrompt}
/>
)}
</div>
<IdeaOutput
value={ideaOutput}
onChange={setIdeaOutput}
/>
<div className="mt-7 flex justify-end space-x-2">
<Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`, { ns: 'appDebug' })}</Button>
<Button
className="flex space-x-1"
variant="primary"
onClick={onGenerate}
disabled={isLoading}
>
<Generator className="h-4 w-4" />
<span className="text-xs font-semibold">{t('generate.generate', { ns: 'appDebug' })}</span>
</Button>
</div>
</div>
</div>
{(!isLoading && current) && (
<div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0">
<Result
current={current!}
isBasicMode={isBasicMode}
nodeId={nodeId!}
currentVersionIndex={currentVersionIndex || 0}
setCurrentVersionIndex={setCurrentVersionIndex}
versions={versions || []}
onApply={showConfirmOverwrite}
generatorType={GeneratorType.prompt}
/>
</div>
)}
{/* inputs */}
<div className="mt-4">
<div>
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('generate.instruction', { ns: 'appDebug' })}</div>
{isBasicMode
? (
<InstructionEditorInBasic
editorKey={editorKey}
generatorType={GeneratorType.prompt}
value={instruction}
onChange={setInstruction}
availableVars={[]}
availableNodes={[]}
isShowCurrentBlock={!!currentPrompt}
isShowLastRunBlock={false}
/>
)
: (
<InstructionEditorInWorkflow
editorKey={editorKey}
generatorType={GeneratorType.prompt}
value={instruction}
onChange={setInstruction}
nodeId={nodeId || ''}
isShowCurrentBlock={!!currentPrompt}
/>
)}
</div>
<IdeaOutput
value={ideaOutput}
onChange={setIdeaOutput}
/>
<div className="mt-7 flex justify-end space-x-2">
<Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`, { ns: 'appDebug' })}</Button>
<Button
className="flex space-x-1"
variant="primary"
onClick={onGenerate}
disabled={isLoading}
>
<Generator className="h-4 w-4" />
<span className="text-xs font-semibold">{t('generate.generate', { ns: 'appDebug' })}</span>
</Button>
</div>
</div>
{isLoading && renderLoading}
{isShowAutoPromptResPlaceholder() && <ResPlaceholder />}
<AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('generate.overwriteTitle', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('generate.overwriteMessage', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
{(!isLoading && current) && (
<div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0">
<Result
current={current!}
isBasicMode={isBasicMode}
nodeId={nodeId!}
currentVersionIndex={currentVersionIndex || 0}
setCurrentVersionIndex={setCurrentVersionIndex}
versions={versions || []}
onApply={showConfirmOverwrite}
generatorType={GeneratorType.prompt}
/>
</div>
)}
{isLoading && renderLoading}
{isShowAutoPromptResPlaceholder() && <ResPlaceholder />}
<AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('generate.overwriteTitle', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('generate.overwriteMessage', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
</Modal>
</DialogContent>
</Dialog>
)
}
export default React.memo(GetAutomaticRes)

View File

@ -13,6 +13,7 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import {
useBoolean,
@ -23,7 +24,6 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@ -202,99 +202,104 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
)
return (
<Modal
isShow={isShow}
onClose={onClose}
className="min-w-[1140px] p-0!"
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open)
onClose()
}}
>
<div className="relative flex h-[680px] flex-wrap">
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('codegen.title', { ns: 'appDebug' })}</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('codegen.description', { ns: 'appDebug' })}</div>
</div>
<div className="mb-4">
<ModelParameterModal
popupClassName="w-[520px]!"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<div>
<div className="text-[0px]">
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div>
<InstructionEditor
editorKey={editorKey}
value={instruction}
onChange={setInstruction}
nodeId={nodeId}
generatorType={GeneratorType.code}
isShowCurrentBlock={!!currentCode}
<DialogContent className="max-h-none w-full min-w-[1140px] overflow-hidden! border-none p-0! text-left align-middle">
<div className="relative flex h-[680px] flex-wrap">
<div className="h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6">
<div className="mb-5">
<div className={`text-lg leading-[28px] font-bold ${s.textGradient}`}>{t('codegen.title', { ns: 'appDebug' })}</div>
<div className="mt-1 text-[13px] font-normal text-text-tertiary">{t('codegen.description', { ns: 'appDebug' })}</div>
</div>
<div className="mb-4">
<ModelParameterModal
popupClassName="w-[520px]!"
isAdvancedMode={true}
provider={model.provider}
completionParams={model.completion_params}
modelId={model.name}
setModel={handleModelChange}
onCompletionParamsChange={handleCompletionParamsChange}
hideDebugWithMultipleModel
/>
</div>
<IdeaOutput
value={ideaOutput}
onChange={setIdeaOutput}
/>
<div>
<div className="text-[0px]">
<div className="mb-1.5 system-sm-semibold-uppercase text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div>
<InstructionEditor
editorKey={editorKey}
value={instruction}
onChange={setInstruction}
nodeId={nodeId}
generatorType={GeneratorType.code}
isShowCurrentBlock={!!currentCode}
/>
</div>
<IdeaOutput
value={ideaOutput}
onChange={setIdeaOutput}
/>
<div className="mt-7 flex justify-end space-x-2">
<Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`, { ns: 'appDebug' })}</Button>
<Button
className="flex space-x-1"
variant="primary"
onClick={onGenerate}
disabled={isLoading}
>
<Generator className="h-4 w-4" />
<span className="text-xs font-semibold">{t('codegen.generate', { ns: 'appDebug' })}</span>
</Button>
<div className="mt-7 flex justify-end space-x-2">
<Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`, { ns: 'appDebug' })}</Button>
<Button
className="flex space-x-1"
variant="primary"
onClick={onGenerate}
disabled={isLoading}
>
<Generator className="h-4 w-4" />
<span className="text-xs font-semibold">{t('codegen.generate', { ns: 'appDebug' })}</span>
</Button>
</div>
</div>
</div>
{isLoading && renderLoading}
{!isLoading && !current && <ResPlaceholder />}
{(!isLoading && current) && (
<div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0">
<Result
current={current!}
currentVersionIndex={currentVersionIndex || 0}
setCurrentVersionIndex={setCurrentVersionIndex}
versions={versions || []}
onApply={showConfirmOverwrite}
generatorType={GeneratorType.code}
/>
</div>
)}
</div>
{isLoading && renderLoading}
{!isLoading && !current && <ResPlaceholder />}
{(!isLoading && current) && (
<div className="h-full w-0 grow bg-background-default-subtle p-6 pb-0">
<Result
current={current!}
currentVersionIndex={currentVersionIndex || 0}
setCurrentVersionIndex={setCurrentVersionIndex}
versions={versions || []}
onApply={showConfirmOverwrite}
generatorType={GeneratorType.code}
/>
</div>
)}
</div>
<AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('codegen.overwriteConfirmTitle', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('codegen.overwriteConfirmMessage', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Modal>
<AlertDialog open={isShowConfirmOverwrite} onOpenChange={open => !open && hideShowConfirmOverwrite()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('codegen.overwriteConfirmTitle', { ns: 'appDebug' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('codegen.overwriteConfirmMessage', { ns: 'appDebug' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
onClick={() => {
hideShowConfirmOverwrite()
onFinished(current!)
}}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
)
}

View File

@ -13,37 +13,6 @@ import { RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_TYPE } from '@/types/app'
import ParamsConfig from '../index'
vi.mock('@headlessui/react', () => ({
Dialog: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div role="dialog" className={className}>
{children}
</div>
),
DialogPanel: ({ children, className, ...props }: { children: React.ReactNode, className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
DialogTitle: ({ children, className, ...props }: { children: React.ReactNode, className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
Transition: ({ show, children }: { show: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null),
TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Switch: ({ checked, onChange, children, ...props }: { checked: boolean, onChange?: (value: boolean) => void, children?: React.ReactNode }) => (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange?.(!checked)}
{...props}
>
{children}
</button>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(),
useCurrentProviderAndModel: vi.fn(),

View File

@ -3,12 +3,12 @@ import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiEqualizer2Line } from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Modal from '@/app/components/base/modal'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import {
@ -123,32 +123,36 @@ const ParamsConfig = ({
</Button>
{
rerankSettingModalOpen && (
<Modal
isShow={rerankSettingModalOpen}
onClose={() => {
setRerankSettingModalOpen(false)
<Dialog
open={rerankSettingModalOpen}
onOpenChange={(open) => {
if (!open) {
setRerankSettingModalOpen(false)
}
}}
className="sm:min-w-[528px]"
>
<ConfigContent
datasetConfigs={tempDataSetConfigs}
onChange={handleSetTempDataSetConfigs}
selectedDatasets={selectedDatasets}
/>
<DialogContent className="w-full max-w-[480px] overflow-hidden! border-none text-left align-middle sm:min-w-[528px]">
<div className="mt-6 flex justify-end">
<Button
className="mr-2 shrink-0"
onClick={() => {
setTempDataSetConfigs(datasetConfigs)
setRerankSettingModalOpen(false)
}}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" className="shrink-0" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</Modal>
<ConfigContent
datasetConfigs={tempDataSetConfigs}
onChange={handleSetTempDataSetConfigs}
selectedDatasets={selectedDatasets}
/>
<div className="mt-6 flex justify-end">
<Button
className="mr-2 shrink-0"
onClick={() => {
setTempDataSetConfigs(datasetConfigs)
setRerankSettingModalOpen(false)
}}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" className="shrink-0" onClick={handleSave}>{t('operation.save', { ns: 'common' })}</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,51 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
type CreateAppDialogShellProps = {
children: ReactNode
contentClassName?: string
onClose: () => void
show: boolean
title: ReactNode
}
export function CreateAppDialogShell({
children,
contentClassName,
onClose,
show,
title,
}: CreateAppDialogShellProps) {
return (
<Dialog
open={show}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<DialogContent
backdropClassName="bg-background-overlay-backdrop backdrop-blur-[6px]"
className="top-0 left-0 h-screen max-h-none w-screen max-w-none translate-x-0 translate-y-0 overflow-hidden rounded-none border-none bg-transparent p-4 shadow-none"
>
<div className="h-full w-full rounded-2xl border border-effects-highlight bg-background-default-subtle">
<div className={cn('relative h-full overflow-hidden', contentClassName)}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<button
type="button"
aria-label="Close"
className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover"
onClick={onClose}
>
<span aria-hidden="true" className="i-ri-close-large-line h-3.5 w-3.5 text-components-button-tertiary-text" />
</button>
{children}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -25,17 +25,6 @@ vi.mock('../app-list', () => ({
},
}))
// Store captured callbacks from useKeyPress
let capturedEscCallback: (() => void) | undefined
const mockUseKeyPress = vi.fn((key: string, callback: () => void) => {
if (key === 'esc')
capturedEscCallback = callback
})
vi.mock('ahooks', () => ({
useKeyPress: (key: string, callback: () => void) => mockUseKeyPress(key, callback),
}))
describe('CreateAppTemplateDialog', () => {
const defaultProps = {
show: false,
@ -46,53 +35,18 @@ describe('CreateAppTemplateDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedEscCallback = undefined
})
describe('Rendering', () => {
it('should not render when show is false', () => {
render(<CreateAppTemplateDialog {...defaultProps} />)
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
// FullScreenModal should not render any content when open is false
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render modal when show is true', () => {
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
// FullScreenModal renders with role="dialog"
// FullScreenModal renders with role="dialog"
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
expect(screen.getByTestId('app-list'))!.toBeInTheDocument()
})
@ -113,7 +67,7 @@ describe('CreateAppTemplateDialog', () => {
})
describe('Props', () => {
it('should pass show prop to FullScreenModal', () => {
it('should pass show prop to the dialog shell', () => {
const { rerender } = render(<CreateAppTemplateDialog {...defaultProps} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
@ -122,15 +76,17 @@ describe('CreateAppTemplateDialog', () => {
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
})
it('should pass closable prop to FullScreenModal', () => {
// Since the FullScreenModal is always rendered with closable=true
// we can verify that the modal renders with the proper structure
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
it('should close from the dialog shell close button', () => {
const mockOnClose = vi.fn()
// Verify that the modal has the proper dialog structure
const dialog = screen.getByRole('dialog')
expect(dialog)!.toBeInTheDocument()
expect(dialog)!.toHaveAttribute('aria-modal', 'true')
render(<CreateAppTemplateDialog {...defaultProps} show={true} onClose={mockOnClose} />)
const closeButton = screen.getByRole('button', { name: 'Close' })
expect(closeButton)!.toBeInTheDocument()
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})
@ -143,8 +99,6 @@ describe('CreateAppTemplateDialog', () => {
const dialog = screen.getByRole('dialog')
expect(dialog)!.toBeInTheDocument()
// Test that AppList component renders (child component interactions)
// Test that AppList component renders (child component interactions)
expect(screen.getByTestId('app-list'))!.toBeInTheDocument()
expect(screen.getByTestId('app-list-success'))!.toBeInTheDocument()
})
@ -183,65 +137,6 @@ describe('CreateAppTemplateDialog', () => {
})
})
describe('useKeyPress Integration', () => {
it('should set up ESC key listener when modal is shown', () => {
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
expect(mockUseKeyPress).toHaveBeenCalledWith('esc', expect.any(Function))
})
it('should handle ESC key press to close modal', () => {
const mockOnClose = vi.fn()
render(
<CreateAppTemplateDialog
{...defaultProps}
show={true}
onClose={mockOnClose}
/>,
)
expect(capturedEscCallback).toBeDefined()
expect(typeof capturedEscCallback).toBe('function')
// Simulate ESC key press
capturedEscCallback?.()
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should not call onClose when ESC key is pressed and modal is not shown', () => {
const mockOnClose = vi.fn()
render(
<CreateAppTemplateDialog
{...defaultProps}
show={false} // Modal not shown
onClose={mockOnClose}
/>,
)
// The callback should still be created but not execute onClose
expect(capturedEscCallback).toBeDefined()
// Simulate ESC key press
capturedEscCallback?.()
// onClose should not be called because modal is not shown
expect(mockOnClose).not.toHaveBeenCalled()
})
})
describe('Callback Dependencies', () => {
it('should create stable callback reference for ESC key handler', () => {
render(<CreateAppTemplateDialog {...defaultProps} show={true} />)
// Verify that useKeyPress was called with a function
const calls = mockUseKeyPress.mock.calls
expect(calls.length).toBeGreaterThan(0)
expect(calls[0]![0]).toBe('esc')
expect(typeof calls[0]![1]).toBe('function')
})
})
describe('Edge Cases', () => {
it('should handle null props gracefully', () => {
expect(() => {

View File

@ -1,7 +1,6 @@
'use client'
import { useKeyPress } from 'ahooks'
import { useCallback } from 'react'
import FullScreenModal from '@/app/components/base/fullscreen-modal'
import { useTranslation } from 'react-i18next'
import { CreateAppDialogShell } from '../create-app-dialog-shell'
import AppList from './app-list'
type CreateAppDialogProps = {
@ -12,19 +11,10 @@ type CreateAppDialogProps = {
}
const CreateAppTemplateDialog = ({ show, onSuccess, onClose, onCreateFromBlank }: CreateAppDialogProps) => {
const handleEscKeyPress = useCallback(() => {
if (show)
onClose()
}, [show, onClose])
useKeyPress('esc', handleEscKeyPress)
const { t } = useTranslation()
return (
<FullScreenModal
open={show}
closable
onClose={onClose}
>
<CreateAppDialogShell show={show} title={t('newApp.startFromTemplate', { ns: 'app' })} onClose={onClose}>
<AppList
onCreateFromBlank={onCreateFromBlank}
onSuccess={() => {
@ -32,7 +22,7 @@ const CreateAppTemplateDialog = ({ show, onSuccess, onClose, onCreateFromBlank }
onClose()
}}
/>
</FullScreenModal>
</CreateAppDialogShell>
)
}

View File

@ -7,11 +7,10 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import FullScreenModal from '@/app/components/base/fullscreen-modal'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
@ -28,6 +27,7 @@ import { trackCreateApp } from '@/utils/create-app-tracking'
import { basePath } from '@/utils/var'
import AppIconPicker from '../../base/app-icon-picker'
import ShortcutsName from '../../workflow/shortcuts-name'
import { CreateAppDialogShell } from '../create-app-dialog-shell'
type CreateAppProps = {
onSuccess: () => void
@ -36,6 +36,10 @@ type CreateAppProps = {
defaultAppMode?: AppModeEnum
}
const shouldExpandBeginnerAppTypes = (appMode?: AppModeEnum) => {
return appMode === AppModeEnum.CHAT || appMode === AppModeEnum.AGENT_CHAT || appMode === AppModeEnum.COMPLETION
}
function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) {
const { t } = useTranslation()
const { push } = useRouter()
@ -45,7 +49,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(false)
const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(() => shouldExpandBeginnerAppTypes(defaultAppMode))
const { plan, enableBilling } = useProviderContext()
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
@ -53,11 +57,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
const isCreatingRef = useRef(false)
useEffect(() => {
if (appMode === AppModeEnum.CHAT || appMode === AppModeEnum.AGENT_CHAT || appMode === AppModeEnum.COMPLETION)
setIsAppTypeExpanded(true)
}, [appMode])
const onCreate = useCallback(async () => {
if (!appMode) {
toast.error(t('newApp.appTypeRequired', { ns: 'app' }))
@ -88,8 +87,8 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
getRedirection(isCurrentWorkspaceEditor, app, push)
}
catch (e: any) {
toast.error(e.message || t('newApp.appCreateFailed', { ns: 'app' }))
catch (error) {
toast.error(error instanceof Error ? error.message : t('newApp.appCreateFailed', { ns: 'app' }))
}
isCreatingRef.current = false
}, [name, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor])
@ -290,15 +289,17 @@ type CreateAppDialogProps = CreateAppProps & {
show: boolean
}
const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppDialogProps) => {
const { t } = useTranslation()
return (
<FullScreenModal
overflowVisible
closable
open={show}
<CreateAppDialogShell
show={show}
title={t('newApp.startFromBlank', { ns: 'app' })}
contentClassName="overflow-visible"
onClose={onClose}
>
<CreateApp onClose={onClose} onSuccess={onSuccess} onCreateFromTemplate={onCreateFromTemplate} defaultAppMode={defaultAppMode} />
</FullScreenModal>
</CreateAppDialogShell>
)
}

View File

@ -307,6 +307,78 @@ describe('CreateFromDSLModal', () => {
expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW })
})
it('should close the DSL mismatch modal when dialog requests close', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({
id: 'import-close',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
render(
<CreateFromDSLModal
show
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
dslUrl="https://example.com/app.yml"
/>,
)
await act(async () => {
fireEvent.click(getCreateButton())
})
await act(async () => {
vi.advanceTimersByTime(300)
})
expect(screen.getByText('newApp.appCreateDSLErrorTitle'))!.toBeInTheDocument()
vi.useRealTimers()
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
await waitFor(() => {
expect(screen.queryByText('newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument()
})
})
it('should close the DSL mismatch modal when cancel is clicked', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({
id: 'import-cancel',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
render(
<CreateFromDSLModal
show
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
dslUrl="https://example.com/app.yml"
/>,
)
await act(async () => {
fireEvent.click(getCreateButton())
})
await act(async () => {
vi.advanceTimersByTime(300)
})
expect(screen.getByText('newApp.appCreateDSLErrorTitle'))!.toBeInTheDocument()
vi.useRealTimers()
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Cancel' }).at(-1)!)
await waitFor(() => {
expect(screen.queryByText('newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument()
})
})
it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => {
let resolveImport!: (value: { id: string, status: DSLImportStatus, app_id: string, app_mode: string }) => void
mockImportDSL.mockImplementationOnce(() => new Promise((resolve) => {
@ -397,6 +469,7 @@ describe('CreateFromDSLModal', () => {
mockImportDSL.mockResolvedValueOnce({
id: 'import-failed',
status: DSLImportStatus.FAILED,
error: 'Invalid YAML format',
})
mockImportDSL.mockRejectedValueOnce(new Error('boom'))
@ -412,7 +485,7 @@ describe('CreateFromDSLModal', () => {
await act(async () => {
fireEvent.click(getCreateButton())
})
expect(toastMocks.error).toHaveBeenCalledWith('newApp.appCreateFailed')
expect(toastMocks.error).toHaveBeenCalledWith('Invalid YAML format')
rerender(
<CreateFromDSLModal
@ -427,6 +500,7 @@ describe('CreateFromDSLModal', () => {
fireEvent.click(getCreateButton())
})
expect(toastMocks.error).toHaveBeenCalledTimes(2)
expect(toastMocks.error).toHaveBeenLastCalledWith('newApp.appCreateFailed')
})
it('should handle pending import confirmation failures and cancellation', async () => {
@ -438,7 +512,7 @@ describe('CreateFromDSLModal', () => {
current_dsl_version: '2.0.0',
})
mockImportDSLConfirm
.mockResolvedValueOnce({ status: DSLImportStatus.FAILED })
.mockResolvedValueOnce({ status: DSLImportStatus.FAILED, error: 'Confirm failed' })
.mockRejectedValueOnce(new Error('boom'))
render(
@ -465,11 +539,12 @@ describe('CreateFromDSLModal', () => {
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!)
})
expect(toastMocks.error).toHaveBeenCalledWith('newApp.appCreateFailed')
expect(toastMocks.error).toHaveBeenCalledWith('Confirm failed')
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: 'newApp.Confirm' })[0]!)
})
expect(toastMocks.error).toHaveBeenCalledTimes(2)
expect(toastMocks.error).toHaveBeenLastCalledWith('newApp.appCreateFailed')
})
})

View File

@ -1,6 +1,13 @@
import { Button } from '@langgenius/dify-ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
type DSLConfirmModalProps = {
versions?: {
@ -20,32 +27,36 @@ const DSLConfirmModal = ({
const { t } = useTranslation()
return (
<Modal
isShow
onClose={() => onCancel()}
className="w-[480px]"
<AlertDialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="flex grow flex-col system-md-regular text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions.systemVersion}</span>
</div>
<AlertDialogContent className="w-[480px] overflow-hidden! border-none text-left align-middle shadow-xl">
<div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</AlertDialogTitle>
<AlertDialogDescription render={<div />} className="flex grow flex-col system-md-regular text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions.systemVersion}</span>
</div>
</AlertDialogDescription>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={() => onCancel()}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button variant="primary" tone="destructive" onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</Button>
</div>
</Modal>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">{t('newApp.Cancel', { ns: 'app' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>{t('newApp.Confirm', { ns: 'app' })}</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -3,14 +3,12 @@
import type { MouseEventHandler } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@ -47,7 +45,7 @@ export enum CreateFromDSLModalTab {
const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => {
const { push } = useRouter()
const { t } = useTranslation()
const [currentFile, setDSLFile] = useState<File | undefined>(droppedFile)
const [currentFile, setCurrentFile] = useState<File | undefined>(droppedFile)
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState(activeTab)
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
@ -56,22 +54,22 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
const [importId, setImportId] = useState<string>()
const { handleCheckPluginDependencies } = usePluginDependencies()
const readFile = (file: File) => {
const readFile = useCallback((file: File) => {
const reader = new FileReader()
reader.onload = function (event) {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}
}, [])
const handleFile = (file?: File) => {
setDSLFile(file)
const handleFile = useCallback((file?: File) => {
setCurrentFile(file)
if (file)
readFile(file)
if (!file)
setFileContent('')
}
}, [readFile])
const { isCurrentWorkspaceEditor } = useAppContext()
const { plan, enableBilling } = useProviderContext()
@ -82,7 +80,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
useEffect(() => {
if (droppedFile)
handleFile(droppedFile)
}, [droppedFile])
}, [droppedFile, handleFile])
const onCreate = async (_e?: React.MouseEvent) => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
@ -141,11 +139,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
setImportId(id)
}
else {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
toast.error(response.error || t('newApp.appCreateFailed', { ns: 'app' }))
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
catch {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
isCreatingRef.current = false
@ -187,11 +184,10 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
}
else if (status === DSLImportStatus.FAILED) {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
toast.error(response.error || t('newApp.appCreateFailed', { ns: 'app' }))
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
catch {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
}
}
@ -219,108 +215,112 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
return (
<>
<Modal
className="w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-xl"
isShow={show}
onClose={noop}
>
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
{t('importApp', { ns: 'app' })}
<div
className="flex h-8 w-8 cursor-pointer items-center"
onClick={() => onClose()}
>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
<Dialog open={show}>
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
{t('importApp', { ns: 'app' })}
<div
className="flex h-8 w-8 cursor-pointer items-center"
onClick={() => onClose()}
>
<span className="i-ri-close-line h-5 w-5 text-text-tertiary" />
</div>
</div>
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
{
tabs.map(tab => (
<div
key={tab.key}
className={cn(
'relative flex h-full cursor-pointer items-center',
currentTab === tab.key && 'text-text-primary',
)}
onClick={() => setCurrentTab(tab.key)}
>
{tab.label}
{
currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
)
}
</div>
))
}
</div>
<div className="px-6 py-4">
{
currentTab === CreateFromDSLModalTab.FROM_FILE && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
/>
)
}
{
currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="mb-1 system-md-semibold text-text-secondary">DSL URL</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
{
tabs.map(tab => (
<div
key={tab.key}
className={cn(
'relative flex h-full cursor-pointer items-center',
currentTab === tab.key && 'text-text-primary',
)}
onClick={() => setCurrentTab(tab.key)}
>
{tab.label}
{
currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
)
}
</div>
))
}
</div>
<div className="px-6 py-4">
{
currentTab === CreateFromDSLModalTab.FROM_FILE && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
/>
</div>
)
}
</div>
{isAppsFull && (
<div className="px-6">
<AppsFull className="mt-0" loc="app-create-dsl" />
)
}
{
currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="mb-1 system-md-semibold text-text-secondary">DSL URL</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
/>
</div>
)
}
</div>
)}
<div className="flex justify-end px-6 py-5">
<Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button
disabled={buttonDisabled}
variant="primary"
onClick={handleCreateApp}
className="gap-1"
>
<span>{t('newApp.Create', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
</div>
</Modal>
<Modal
isShow={showErrorModal}
onClose={() => setShowErrorModal(false)}
className="w-[480px]"
{isAppsFull && (
<div className="px-6">
<AppsFull className="mt-0" loc="app-create-dsl" />
</div>
)}
<div className="flex justify-end px-6 py-5">
<Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button
disabled={buttonDisabled}
variant="primary"
onClick={handleCreateApp}
className="gap-1"
>
<span>{t('newApp.Create', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={showErrorModal}
onOpenChange={(open) => {
if (!open)
setShowErrorModal(false)
}}
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="flex grow flex-col system-md-regular text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions?.systemVersion}</span>
<DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle">
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="flex grow flex-col system-md-regular text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
</div>
<div>
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
<span className="system-md-medium">{versions?.systemVersion}</span>
</div>
</div>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button variant="primary" tone="destructive" onClick={onDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button>
</div>
</Modal>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={() => setShowErrorModal(false)}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button variant="primary" tone="destructive" onClick={onDSLConfirm}>{t('newApp.Confirm', { ns: 'app' })}</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -1,16 +1,14 @@
'use client'
import type { AppIconType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { useProviderContext } from '@/context/provider-context'
import AppIconPicker from '../../base/app-icon-picker'
@ -71,40 +69,39 @@ const DuplicateAppModal = ({
return (
<>
<Modal
isShow={show}
onClose={noop}
className={cn('relative max-w-[480px]!', 'px-8')}
>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
<div className="relative mt-3 mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('duplicateTitle', { ns: 'app' })}</div>
<div className="mb-9 system-sm-regular text-text-secondary">
<div className="mb-2 system-md-medium">{t('appCustomize.subTitle', { ns: 'explore' })}</div>
<div className="flex items-center justify-between space-x-2">
<AppIcon
size="large"
onClick={() => { setShowAppIconPicker(true) }}
className="cursor-pointer"
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}
/>
<Input
value={name}
onChange={e => setName(e.target.value)}
className="h-10"
/>
<Dialog open={show}>
<DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none px-8 text-left align-middle">
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onHide}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
{isAppsFull && <AppsFull className="mt-4" loc="app-duplicate-create" />}
</div>
<div className="flex flex-row-reverse">
<Button disabled={isAppsFull} className="ml-2 w-24" variant="primary" onClick={submit}>{t('duplicate', { ns: 'app' })}</Button>
<Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
</Modal>
<div className="relative mt-3 mb-9 text-xl leading-[30px] font-semibold text-text-primary">{t('duplicateTitle', { ns: 'app' })}</div>
<div className="mb-9 system-sm-regular text-text-secondary">
<div className="mb-2 system-md-medium">{t('appCustomize.subTitle', { ns: 'explore' })}</div>
<div className="flex items-center justify-between space-x-2">
<AppIcon
size="large"
onClick={() => { setShowAppIconPicker(true) }}
className="cursor-pointer"
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}
/>
<Input
value={name}
onChange={e => setName(e.target.value)}
className="h-10"
/>
</div>
{isAppsFull && <AppsFull className="mt-4" loc="app-duplicate-create" />}
</div>
<div className="flex flex-row-reverse">
<Button disabled={isAppsFull} className="ml-2 w-24" variant="primary" onClick={submit}>{t('duplicate', { ns: 'app' })}</Button>
<Button className="w-24" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
</DialogContent>
</Dialog>
{showAppIconPicker && (
<AppIconPicker
onSelect={(payload) => {

View File

@ -12,9 +12,9 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
@ -22,7 +22,6 @@ import AppIcon from '@/app/components/base/app-icon'
import Checkbox from '@/app/components/base/checkbox'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
@ -109,69 +108,68 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
return (
<>
<Modal
className={cn('w-[600px] max-w-[600px] p-8')}
isShow={show}
onClose={noop}
>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-default-burn p-3 shadow-xl">
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
</div>
<div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('switch', { ns: 'app' })}</div>
<div className="my-1 text-sm leading-5 text-text-tertiary">
<span>{t('switchTipStart', { ns: 'app' })}</span>
<span className="font-medium text-text-secondary">{t('switchTip', { ns: 'app' })}</span>
<span>{t('switchTipEnd', { ns: 'app' })}</span>
</div>
<div className="pb-4">
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('switchLabel', { ns: 'app' })}</div>
<div className="flex items-center justify-between space-x-2">
<AppIcon
size="large"
onClick={() => { setShowAppIconPicker(true) }}
className="cursor-pointer"
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}
/>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('newApp.appNamePlaceholder', { ns: 'app' }) || ''}
className="h-10 grow"
/>
<Dialog open={show}>
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn('w-[600px] max-w-[600px] p-8'))}>
<div className="absolute top-4 right-4 cursor-pointer p-2" onClick={onClose}>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
{showAppIconPicker && (
<AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(appDetail.icon_type === 'image'
? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon }
: { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background })
setShowAppIconPicker(false)
}}
/>
)}
</div>
{isAppsFull && <AppsFull loc="app-switch" />}
<div className="flex items-center justify-between pt-6">
<div className="flex items-center">
<Checkbox className="shrink-0" checked={removeOriginal} onCheck={() => setRemoveOriginal(!removeOriginal)} />
<div className="ml-2 cursor-pointer text-sm leading-5 text-text-secondary" onClick={() => setRemoveOriginal(!removeOriginal)}>{t('removeOriginal', { ns: 'app' })}</div>
<div className="h-12 w-12 rounded-xl border-[0.5px] border-divider-regular bg-background-default-burn p-3 shadow-xl">
<AlertTriangle className="h-6 w-6 text-[rgb(247,144,9)]" />
</div>
<div className="flex items-center">
<Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button className="border-red-700" disabled={isAppsFull || !name} variant="primary" tone="destructive" onClick={goStart}>{t('switchStart', { ns: 'app' })}</Button>
<div className="relative mt-3 text-xl leading-[30px] font-semibold text-text-primary">{t('switch', { ns: 'app' })}</div>
<div className="my-1 text-sm leading-5 text-text-tertiary">
<span>{t('switchTipStart', { ns: 'app' })}</span>
<span className="font-medium text-text-secondary">{t('switchTip', { ns: 'app' })}</span>
<span>{t('switchTipEnd', { ns: 'app' })}</span>
</div>
</div>
</Modal>
<div className="pb-4">
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('switchLabel', { ns: 'app' })}</div>
<div className="flex items-center justify-between space-x-2">
<AppIcon
size="large"
onClick={() => { setShowAppIconPicker(true) }}
className="cursor-pointer"
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}
/>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('newApp.appNamePlaceholder', { ns: 'app' }) || ''}
className="h-10 grow"
/>
</div>
{showAppIconPicker && (
<AppIconPicker
onSelect={(payload) => {
setAppIcon(payload)
setShowAppIconPicker(false)
}}
onClose={() => {
setAppIcon(appDetail.icon_type === 'image'
? { type: 'image' as const, url: appDetail.icon_url, fileId: appDetail.icon }
: { type: 'emoji' as const, icon: appDetail.icon, background: appDetail.icon_background })
setShowAppIconPicker(false)
}}
/>
)}
</div>
{isAppsFull && <AppsFull loc="app-switch" />}
<div className="flex items-center justify-between pt-6">
<div className="flex items-center">
<Checkbox className="shrink-0" checked={removeOriginal} onCheck={() => setRemoveOriginal(!removeOriginal)} />
<div className="ml-2 cursor-pointer text-sm leading-5 text-text-secondary" onClick={() => setRemoveOriginal(!removeOriginal)}>{t('removeOriginal', { ns: 'app' })}</div>
</div>
<div className="flex items-center">
<Button className="mr-2" onClick={onClose}>{t('newApp.Cancel', { ns: 'app' })}</Button>
<Button className="border-red-700" disabled={isAppsFull || !name} variant="primary" tone="destructive" onClick={goStart}>{t('switchStart', { ns: 'app' })}</Button>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog
open={showConfirmDelete}
onOpenChange={handleConfirmDeleteOpenChange}

View File

@ -460,9 +460,10 @@ describe('WorkflowAppLogList', () => {
// Open drawer
const dataRows = screen.getAllByRole('row')
await user.click(dataRows[1]!)
await screen.findByRole('dialog')
const dialog = await screen.findByRole('dialog')
// Close drawer using Escape key
dialog.focus()
await user.keyboard('{Escape}')
await waitFor(() => {

View File

@ -4,15 +4,14 @@ import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { RiImageCircleAiLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
import Divider from '../divider'
import EmojiPickerInner from '../emoji-picker/Inner'
import { useLocalFileUploader } from '../image-uploader/hooks'
import Modal from '../modal'
import ImageInput from './ImageInput'
import s from './style.module.css'
import getCroppedImg from './utils'
@ -45,7 +44,6 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
onSelect,
onClose,
initialEmoji,
className,
}) => {
const { t } = useTranslation()
@ -113,57 +111,54 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
}
return (
<Modal
onClose={noop}
isShow
closable={false}
wrapperClassName={className}
className={cn(s.container, 'h-[462px]! w-[362px]! p-0!')}
>
{!DISABLE_UPLOAD_IMAGE_AS_ICON && (
<div className="w-full p-2 pb-0">
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
{tabs.map(tab => (
<button
type="button"
key={tab.key}
className={cn(
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon}
{' '}
<Dialog open>
<DialogContent className={cn('max-h-none w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[462px]! w-[362px]! p-0!')}>
{!DISABLE_UPLOAD_IMAGE_AS_ICON && (
<div className="w-full p-2 pb-0">
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
{tabs.map(tab => (
<button
type="button"
key={tab.key}
className={cn(
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
)}
onClick={() => setActiveTab(tab.key as AppIconType)}
>
{tab.icon}
{' '}
&nbsp;
{tab.label}
</button>
))}
{tab.label}
</button>
))}
</div>
</div>
)}
{activeTab === 'emoji' && (
<EmojiPickerInner
className={cn('flex-1 overflow-hidden pt-2')}
emoji={initialEmoji?.icon}
background={initialEmoji?.background ?? undefined}
onSelect={handleSelectEmoji}
/>
)}
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => onClose?.()}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
)}
{activeTab === 'emoji' && (
<EmojiPickerInner
className={cn('flex-1 overflow-hidden pt-2')}
emoji={initialEmoji?.icon}
background={initialEmoji?.background ?? undefined}
onSelect={handleSelectEmoji}
/>
)}
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
<Divider className="m-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button className="w-full" onClick={() => onClose?.()}>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</Modal>
</DialogContent>
</Dialog>
)
}

View File

@ -62,18 +62,15 @@ vi.mock('@/next/navigation', () => ({
usePathname: () => '/test',
}))
// Mock Modal to avoid Headless UI issues in tests
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow, title }: { children: React.ReactNode, isShow: boolean, title: React.ReactNode }) => {
if (!isShow)
return null
return (
<div data-testid="modal">
{!!title && <div data-testid="modal-title">{title}</div>}
{children}
</div>
)
},
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: React.ReactNode, open?: boolean }) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="modal">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="modal-title">{children}</div>
),
}))
describe('Sidebar Index', () => {

View File

@ -4,25 +4,13 @@ import userEvent from '@testing-library/user-event'
import * as ReactI18next from 'react-i18next'
import RenameModal from '../rename-modal'
vi.mock('@/app/components/base/modal', () => ({
default: ({
title,
isShow,
children,
}: {
title: ReactNode
isShow: boolean
children: ReactNode
}) => {
if (!isShow)
return null
return (
<div role="dialog">
<h2>{title}</h2>
{children}
</div>
)
},
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ children, open }: { children: ReactNode, open?: boolean }) =>
open === false ? null : <>{children}</>,
DialogContent: ({ children }: { children: ReactNode }) => (
<div role="dialog">{children}</div>
),
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}))
describe('RenameModal', () => {

View File

@ -1,59 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ContentDialog from '../index'
describe('ContentDialog', () => {
it('renders children when show is true', async () => {
render(
<ContentDialog show={true}>
<div>Dialog body</div>
</ContentDialog>,
)
await screen.findByText('Dialog body')
expect(screen.getByText('Dialog body')).toBeInTheDocument()
const backdrop = document.querySelector('.bg-app-detail-overlay-bg')
expect(backdrop).toBeTruthy()
})
it('does not render children when show is false', () => {
render(
<ContentDialog show={false}>
<div>Hidden content</div>
</ContentDialog>,
)
expect(screen.queryByText('Hidden content')).toBeNull()
expect(document.querySelector('.bg-app-detail-overlay-bg')).toBeNull()
})
it('calls onClose when backdrop is clicked', async () => {
const onClose = vi.fn()
render(
<ContentDialog show={true} onClose={onClose}>
<div>Body</div>
</ContentDialog>,
)
const user = userEvent.setup()
const backdrop = document.querySelector('.bg-app-detail-overlay-bg') as HTMLElement | null
expect(backdrop).toBeTruthy()
await user.click(backdrop!)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('applies provided className to the content panel', () => {
render(
<ContentDialog show={true} className="my-panel-class">
<div>Panel content</div>
</ContentDialog>,
)
const contentPanel = document.querySelector('.bg-app-detail-bg') as HTMLElement | null
expect(contentPanel).toBeTruthy()
expect(contentPanel?.className).toContain('my-panel-class')
expect(screen.getByText('Panel content')).toBeInTheDocument()
})
})

View File

@ -1,119 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useEffect, useState } from 'react'
import ContentDialog from '.'
type Props = React.ComponentProps<typeof ContentDialog>
const meta = {
title: 'Base/Feedback/ContentDialog',
component: ContentDialog,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Sliding panel overlay used in the app detail view. Includes dimmed backdrop and animated entrance/exit transitions.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Additional classes applied to the sliding panel container.',
},
show: {
control: 'boolean',
description: 'Controls visibility of the dialog.',
},
onClose: {
control: false,
description: 'Invoked when the overlay/backdrop is clicked.',
},
children: {
control: false,
table: { disable: true },
},
},
args: {
show: false,
children: null,
},
} satisfies Meta<typeof ContentDialog>
export default meta
type Story = StoryObj<typeof meta>
const DemoWrapper = (props: Props) => {
const [open, setOpen] = useState(props.show)
useEffect(() => {
setOpen(props.show)
}, [props.show])
return (
<div className="relative h-[480px] w-full overflow-hidden bg-gray-100">
<div className="flex h-full items-center justify-center">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open dialog
</button>
</div>
<ContentDialog
{...props}
show={open}
onClose={() => {
props.onClose?.()
setOpen(false)
}}
>
<div className="flex h-full flex-col space-y-4 bg-white p-6">
<h2 className="text-lg font-semibold text-gray-900">Plan summary</h2>
<p className="text-sm text-gray-600">
Use this area to present rich content for the selected run, configuration details, or
any supporting context.
</p>
<div className="flex-1 overflow-y-auto rounded-md border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
Scrollable placeholder content. Add domain-specific information, activity logs, or
editors in the real application.
</div>
<div className="flex justify-end gap-2 pt-4">
<button
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setOpen(false)}
>
Cancel
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Apply changes
</button>
</div>
</div>
</ContentDialog>
</div>
)
}
export const Default: Story = {
args: {
children: null,
},
render: args => <DemoWrapper {...args} />,
}
export const NarrowPanel: Story = {
render: args => <DemoWrapper {...args} />,
args: {
className: 'max-w-[420px]',
children: null,
},
parameters: {
docs: {
description: {
story: 'Applies a custom width class to show the dialog as a narrower information panel.',
},
},
},
}

View File

@ -1,40 +0,0 @@
import type { ReactNode } from 'react'
import { Transition, TransitionChild } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
type ContentDialogProps = {
className?: string
show: boolean
onClose?: () => void
children: ReactNode
}
const ContentDialog = ({
className,
show,
onClose,
children,
}: ContentDialogProps) => {
return (
<Transition
show={show}
as="div"
className="absolute top-0 left-0 z-[70] box-border h-full w-full p-2"
>
<TransitionChild>
<div
className={cn('absolute inset-0 left-0 w-full bg-app-detail-overlay-bg', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')}
onClick={onClose}
/>
</TransitionChild>
<TransitionChild>
<div className={cn('absolute left-0 w-full border-r border-divider-burn bg-app-detail-bg', 'duration-100 ease-in data-closed:-translate-x-full', 'data-enter:translate-x-0 data-enter:duration-300 data-enter:ease-out', 'data-leave:-translate-x-full data-leave:duration-200 data-leave:ease-in', className)}>
{children}
</div>
</TransitionChild>
</Transition>
)
}
export default ContentDialog

View File

@ -31,7 +31,6 @@ const DatePicker = ({
needTimePicker = true,
renderTrigger,
triggerWrapClassName,
popupZIndexClassname,
noConfirm,
getIsDateDisabled,
}: DatePickerProps) => {
@ -236,7 +235,6 @@ const DatePicker = ({
<PopoverContent
placement="bottom-end"
sideOffset={0}
className={popupZIndexClassname}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">

View File

@ -35,7 +35,6 @@ const DatePickerPlayground = (props: DatePickerProps) => {
return (
<div className="inline-flex flex-col items-start gap-3">
<DatePicker
popupZIndexClassname="z-50"
{...props}
value={value}
onChange={setValue}
@ -65,7 +64,6 @@ export const Playground: Story = {
const [value, setValue] = useState(getDateWithTimezone({}))
<DatePicker
popupZIndexClassname="z-50"
value={value}
timezone={dayjs.tz.guess()}
onChange={setValue}

View File

@ -30,7 +30,6 @@ export type DatePickerProps = {
triggerWrapClassName?: string
renderTrigger?: (props: TriggerProps) => React.ReactElement
minuteFilter?: (minutes: string[]) => string[]
popupZIndexClassname?: string
noConfirm?: boolean
getIsDateDisabled?: (date: Dayjs) => boolean
}

View File

@ -1,138 +0,0 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import CustomDialog from '../index'
describe('CustomDialog Component', () => {
const setup = () => userEvent.setup()
it('should render children and title when show is true', async () => {
render(
<CustomDialog show={true} title="Modal Title">
<div data-testid="dialog-content">Main Content</div>
</CustomDialog>,
)
const title = await screen.findByText('Modal Title')
const content = screen.getByTestId('dialog-content')
expect(title).toBeInTheDocument()
expect(content).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should not render anything when show is false', async () => {
render(
<CustomDialog show={false} title="Hidden Title">
<div>Content</div>
</CustomDialog>,
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument()
})
it('should apply the correct semantic tag to title using titleAs', async () => {
render(
<CustomDialog show={true} title="Semantic Title" titleAs="h1">
Content
</CustomDialog>,
)
const title = await screen.findByRole('heading', { level: 1 })
expect(title).toHaveTextContent('Semantic Title')
})
it('should render the footer only when the prop is provided', async () => {
const { rerender } = render(
<CustomDialog show={true}>Content</CustomDialog>,
)
await screen.findByRole('dialog')
expect(screen.queryByText('Footer Content')).not.toBeInTheDocument()
rerender(
<CustomDialog show={true} footer={<div data-testid="footer-node">Footer Content</div>}>
Content
</CustomDialog>,
)
expect(await screen.findByTestId('footer-node')).toBeInTheDocument()
})
it('should call onClose when Escape key is pressed', async () => {
const user = setup()
const onCloseMock = vi.fn()
render(
<CustomDialog show={true} onClose={onCloseMock}>
Content
</CustomDialog>,
)
await screen.findByRole('dialog')
await act(async () => {
await user.keyboard('{Escape}')
})
expect(onCloseMock).toHaveBeenCalledTimes(1)
})
it('should call onClose when the backdrop is clicked', async () => {
const user = setup()
const onCloseMock = vi.fn()
render(
<CustomDialog show={true} onClose={onCloseMock}>
Content
</CustomDialog>,
)
await screen.findByRole('dialog')
const backdrop = document.querySelector('.bg-background-overlay-backdrop')
expect(backdrop).toBeInTheDocument()
await act(async () => {
await user.click(backdrop!)
})
expect(onCloseMock).toHaveBeenCalledTimes(1)
})
it('should apply custom class names to internal elements', async () => {
render(
<CustomDialog
show={true}
title="Title"
className="custom-panel-container"
titleClassName="custom-title-style"
bodyClassName="custom-body-style"
footer="Footer"
footerClassName="custom-footer-style"
>
<div data-testid="content">Content</div>
</CustomDialog>,
)
await screen.findByRole('dialog')
expect(document.querySelector('.custom-panel-container')).toBeInTheDocument()
expect(document.querySelector('.custom-title-style')).toBeInTheDocument()
expect(document.querySelector('.custom-body-style')).toBeInTheDocument()
expect(document.querySelector('.custom-footer-style')).toBeInTheDocument()
})
it('should maintain accessibility attributes (aria-modal)', async () => {
render(
<CustomDialog show={true} title="Accessibility Test">
<button>Focusable Item</button>
</CustomDialog>,
)
const dialog = await screen.findByRole('dialog')
// Headless UI should automatically set aria-modal="true"
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
})

View File

@ -1,152 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useEffect, useState } from 'react'
import Dialog from '.'
const meta = {
title: 'Base/Feedback/Dialog',
component: Dialog,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Modal dialog built on Headless UI. Provides animated overlay, title slot, and optional footer region.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Additional classes applied to the panel.',
},
titleClassName: {
control: 'text',
description: 'Extra classes for the title element.',
},
bodyClassName: {
control: 'text',
description: 'Extra classes for the content area.',
},
footerClassName: {
control: 'text',
description: 'Extra classes for the footer container.',
},
title: {
control: 'text',
description: 'Dialog title.',
},
show: {
control: 'boolean',
description: 'Controls visibility of the dialog.',
},
onClose: {
control: false,
description: 'Called when the dialog backdrop or close handler fires.',
},
},
args: {
title: 'Manage API Keys',
show: false,
children: null,
},
} satisfies Meta<typeof Dialog>
export default meta
type Story = StoryObj<typeof meta>
const DialogDemo = (props: React.ComponentProps<typeof Dialog>) => {
const [open, setOpen] = useState(props.show)
useEffect(() => {
setOpen(props.show)
}, [props.show])
return (
<div className="relative flex h-[480px] items-center justify-center bg-gray-100">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Show dialog
</button>
<Dialog
{...props}
show={open}
onClose={() => {
props.onClose?.()
setOpen(false)
}}
>
<div className="space-y-4 text-sm text-gray-600">
<p>
Centralize API key management for collaborators. You can revoke, rotate, or generate new keys directly from this dialog.
</p>
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
This placeholder area represents a form or table that would live inside the dialog body.
</div>
</div>
</Dialog>
</div>
)
}
export const Default: Story = {
render: args => <DialogDemo {...args} />,
args: {
footer: (
<>
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50">
Cancel
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Save changes
</button>
</>
),
},
}
export const WithoutFooter: Story = {
render: args => <DialogDemo {...args} />,
args: {
footer: undefined,
title: 'Read-only summary',
},
parameters: {
docs: {
description: {
story: 'Demonstrates the dialog when no footer actions are provided.',
},
},
},
}
export const CustomStyling: Story = {
render: args => <DialogDemo {...args} />,
args: {
className: 'max-w-[560px] bg-white/95 backdrop-blur-sm',
bodyClassName: 'bg-gray-50 rounded-xl p-5',
footerClassName: 'justify-between px-4 pb-4 pt-4',
titleClassName: 'text-lg text-primary-600',
footer: (
<>
<span className="text-xs text-gray-400">Last synced 2 minutes ago</span>
<div className="flex gap-2">
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50">
Close
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Refresh data
</button>
</div>
</>
),
},
parameters: {
docs: {
description: {
story: 'Applies custom classes to the panel, body, title, and footer to match different surfaces.',
},
},
},
}

View File

@ -1,70 +0,0 @@
import type { ElementType, ReactNode } from 'react'
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { Fragment, useCallback } from 'react'
// https://headlessui.com/react/dialog
type DialogProps = {
className?: string
titleClassName?: string
bodyClassName?: string
footerClassName?: string
titleAs?: ElementType
title?: ReactNode
children: ReactNode
footer?: ReactNode
show: boolean
onClose?: () => void
}
const CustomDialog = ({
className,
titleClassName,
bodyClassName,
footerClassName,
titleAs,
title,
children,
footer,
show,
onClose,
}: DialogProps) => {
const close = useCallback(() => onClose?.(), [onClose])
return (
<Transition appear show={show} as={Fragment}>
<Dialog as="div" className="relative z-40" onClose={close}>
<TransitionChild>
<div className={cn('fixed inset-0 bg-background-overlay-backdrop backdrop-blur-[6px]', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')} />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center">
<TransitionChild>
<DialogPanel className={cn('w-full max-w-[800px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl transition-all', 'duration-100 ease-in data-closed:scale-95 data-closed:opacity-0', 'data-enter:scale-100 data-enter:opacity-100', 'data-enter:scale-95 data-leave:opacity-0', className)}>
{Boolean(title) && (
<DialogTitle
as={titleAs || 'h3'}
className={cn('pr-8 pb-3 title-2xl-semi-bold text-text-primary', titleClassName)}
>
{title}
</DialogTitle>
)}
<div className={cn(bodyClassName)}>
{children}
</div>
{Boolean(footer) && (
<div className={cn('flex items-center justify-end gap-2 px-6 pt-3 pb-6', footerClassName)}>
{footer}
</div>
)}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
)
}
export default CustomDialog

View File

@ -172,8 +172,7 @@ describe('DrawerPlus', () => {
/>,
)
const dialog = screen.getByRole('dialog')
expect(dialog.className).toContain('custom-dialog')
expect(document.querySelector('.custom-dialog')).toBeInTheDocument()
})
it('should apply custom contentClassName', () => {

View File

@ -6,52 +6,60 @@ import Drawer from '../index'
// Capture dialog onClose for testing
let capturedDialogOnClose: (() => void) | null = null
// Mock @headlessui/react
vi.mock('@headlessui/react', () => ({
Dialog: ({ children, open, onClose, className, unmount }: {
children: React.ReactNode
open: boolean
onClose: () => void
className: string
unmount: boolean
}) => {
capturedDialogOnClose = onClose
if (!open)
return null
return (
// Mock Base UI Dialog anatomy; behavior is covered at the legacy wrapper boundary here.
vi.mock('@base-ui/react/dialog', () => ({
Dialog: {
Root: ({ children, open, onOpenChange }: {
children: React.ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) => {
capturedDialogOnClose = () => onOpenChange(false)
if (!open)
return null
return <>{children}</>
},
Portal: ({ children }: {
children: React.ReactNode
}) => <>{children}</>,
Backdrop: ({ children, className }: {
children?: React.ReactNode
className: string
}) => (
<div
data-testid="dialog"
data-open={open}
data-unmount={unmount}
data-testid="dialog-backdrop"
className={className}
role="dialog"
onClick={() => capturedDialogOnClose?.()}
>
{children}
</div>
)
),
Popup: ({ children, className, ...props }: {
children: React.ReactNode
className: string
}) => (
<div
data-testid="dialog"
className={className}
role="dialog"
{...props}
>
{children}
</div>
),
Title: ({ children, className, render, ...props }: {
children: React.ReactNode
className?: string
render?: React.ReactElement
}) => {
const Component = render?.type ?? 'h2'
return (
<Component data-testid="dialog-title" className={className} {...props}>
{children}
</Component>
)
},
},
DialogBackdrop: ({ children, className, onClick }: {
children?: React.ReactNode
className: string
onClick: () => void
}) => (
<div
data-testid="dialog-backdrop"
className={className}
onClick={onClick}
>
{children}
</div>
),
DialogTitle: ({ children, as: _as, className, ...props }: {
children: React.ReactNode
as?: string
className?: string
}) => (
<div data-testid="dialog-title" className={className} {...props}>
{children}
</div>
),
}))
// Mock XMarkIcon
@ -343,10 +351,10 @@ describe('Drawer', () => {
describe('Custom ClassNames', () => {
it('should apply custom dialogClassName', () => {
// Arrange & Act
renderDrawer({ dialogClassName: 'custom-dialog-class' })
const { container } = renderDrawer({ dialogClassName: 'custom-dialog-class' })
// Assert
expect(screen.getByRole('dialog').className).toContain('custom-dialog-class')
expect(container.querySelector('.custom-dialog-class')).toBeInTheDocument()
})
it('should apply custom dialogBackdropClassName', () => {

View File

@ -10,7 +10,7 @@ const meta = {
layout: 'fullscreen',
docs: {
description: {
component: 'Sliding panel built on Headless UI dialog primitives. Supports optional mask, custom footer, and close behaviour.',
component: 'Sliding panel built on Base UI dialog primitives. Supports optional mask, custom footer, and close behaviour.',
},
},
},

View File

@ -1,5 +1,6 @@
'use client'
import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react'
// eslint-disable-next-line no-restricted-imports -- Temporary legacy drawer exception: remove this direct Base UI wrapper after callers migrate to dify-ui drawer primitives.
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
@ -47,80 +48,81 @@ export default function Drawer({
}: IDrawerProps) {
const { t } = useTranslation()
return (
<Dialog
unmount={unmount}
<BaseDialog.Root
open={isOpen}
onClose={() => {
if (!clickOutsideNotOpen)
disablePointerDismissal={clickOutsideNotOpen}
onOpenChange={(open) => {
if (!open && !clickOutsideNotOpen)
onClose()
}}
className={cn('fixed inset-0 z-30 overflow-y-auto', dialogClassName)}
>
<div className={cn('flex h-screen w-screen justify-end', positionCenter && 'justify-center!', containerClassName)}>
{/* mask */}
{!noOverlay && (
<DialogBackdrop
className={cn('fixed inset-0 z-40', mask && 'bg-black/30', dialogBackdropClassName)}
onClick={() => {
if (!clickOutsideNotOpen)
onClose()
}}
/>
)}
<div className={cn('relative z-50 flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassName)}>
<>
<div className="flex justify-between">
{title && (
<DialogTitle
as="h3"
className="text-lg leading-6 font-medium text-text-primary"
>
{title}
</DialogTitle>
)}
{showClose && (
<DialogTitle className="mb-4 flex cursor-pointer items-center" as="div">
<span
className="i-heroicons-x-mark h-4 w-4 text-text-tertiary"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
onClose()
}}
role="button"
tabIndex={0}
aria-label={t('operation.close', { ns: 'common' })}
data-testid="close-icon"
/>
</DialogTitle>
)}
</div>
{description && <div className="mt-2 text-xs font-normal text-text-tertiary">{description}</div>}
{children}
</>
{footer || (footer === null
? null
: (
<div className="mt-10 flex flex-row justify-end">
<Button
className="mr-2"
onClick={() => {
onCancel?.()
}}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
onClick={() => {
onOk?.()
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
<BaseDialog.Portal>
<div className={cn('fixed inset-0 z-30 overflow-y-auto', dialogClassName)}>
<div className={cn('flex h-screen w-screen justify-end', positionCenter && 'justify-center!', containerClassName)}>
{!noOverlay && (
<BaseDialog.Backdrop
className={cn('fixed inset-0 z-40', mask && 'bg-black/30', dialogBackdropClassName)}
/>
)}
<BaseDialog.Popup
data-unmount={unmount}
className={cn('relative z-50 flex w-full max-w-sm flex-col justify-between overflow-hidden bg-components-panel-bg p-6 text-left align-middle shadow-xl', panelClassName)}
>
<>
<div className="flex justify-between">
{title && (
<BaseDialog.Title
render={<h3 />}
className="text-lg leading-6 font-medium text-text-primary"
>
{title}
</BaseDialog.Title>
)}
{showClose && (
<div className="mb-4 flex cursor-pointer items-center">
<span
className="i-heroicons-x-mark h-4 w-4 text-text-tertiary"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
onClose()
}}
role="button"
tabIndex={0}
aria-label={t('operation.close', { ns: 'common' })}
data-testid="close-icon"
/>
</div>
)}
</div>
))}
{description && <div className="mt-2 text-xs font-normal text-text-tertiary">{description}</div>}
{children}
</>
{footer || (footer === null
? null
: (
<div className="mt-10 flex flex-row justify-end">
<Button
className="mr-2"
onClick={() => {
onCancel?.()
}}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
onClick={() => {
onOk?.()
}}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
))}
</BaseDialog.Popup>
</div>
</div>
</div>
</Dialog>
</BaseDialog.Portal>
</BaseDialog.Root>
)
}

View File

@ -2,12 +2,11 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Modal from '@/app/components/base/modal'
import EmojiPickerInner from './Inner'
type IEmojiPickerProps = {
@ -34,39 +33,42 @@ const EmojiPicker: FC<IEmojiPickerProps> = ({
return isModal
? (
<Modal
onClose={noop}
isShow
closable={false}
wrapperClassName={className}
className={cn('flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl')}
>
<EmojiPickerInner
className="pt-3"
onSelect={handleSelectEmoji}
/>
<Divider className="mt-3 mb-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button
className="w-full"
onClick={() => {
onClose?.()
}}
>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className="w-full"
onClick={() => {
onSelect?.(selectedEmoji, selectedBackground!)
}}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</Modal>
<Dialog open>
<DialogContent
className={cn(
'max-h-none w-full overflow-hidden! text-left align-middle',
'flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl',
className,
)}
>
<EmojiPickerInner
className="pt-3"
onSelect={handleSelectEmoji}
/>
<Divider className="mt-3 mb-0" />
<div className="flex w-full items-center justify-center gap-2 p-3">
<Button
className="w-full"
onClick={() => {
onClose?.()
}}
>
{t('iconPicker.cancel', { ns: 'app' })}
</Button>
<Button
disabled={selectedEmoji === '' || !selectedBackground}
variant="primary"
className="w-full"
onClick={() => {
onSelect?.(selectedEmoji, selectedBackground!)
}}
>
{t('iconPicker.ok', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
: <></>
}

View File

@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import DialogWrapper from '../dialog-wrapper'
import { FeaturePanelDrawer } from '../feature-panel-drawer'
describe('DialogWrapper', () => {
describe('FeaturePanelDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@ -9,9 +9,9 @@ describe('DialogWrapper', () => {
describe('Rendering', () => {
it('should render children when show is true', () => {
render(
<DialogWrapper show>
<FeaturePanelDrawer show>
<div data-testid="content">Content</div>
</DialogWrapper>,
</FeaturePanelDrawer>,
)
expect(screen.getByTestId('content')).toBeInTheDocument()
@ -19,9 +19,9 @@ describe('DialogWrapper', () => {
it('should not render children when show is false', () => {
render(
<DialogWrapper show={false}>
<FeaturePanelDrawer show={false}>
<div data-testid="content">Content</div>
</DialogWrapper>,
</FeaturePanelDrawer>,
)
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
@ -31,45 +31,44 @@ describe('DialogWrapper', () => {
describe('Props', () => {
it('should apply workflow styles by default', () => {
render(
<DialogWrapper show>
<FeaturePanelDrawer show>
<div data-testid="content">Content</div>
</DialogWrapper>,
</FeaturePanelDrawer>,
)
const wrapper = screen.getByTestId('content').parentElement
expect(wrapper).toHaveClass('rounded-l-2xl')
expect(wrapper).not.toHaveClass('rounded-2xl')
const drawer = screen.getByRole('dialog')
expect(drawer).toHaveClass('data-[swipe-direction=right]:!top-[112px]')
expect(drawer).toHaveClass('data-[swipe-direction=right]:!rounded-l-2xl')
expect(drawer).not.toHaveClass('data-[swipe-direction=right]:!rounded-2xl')
})
it('should apply non-workflow styles when inWorkflow is false', () => {
render(
<DialogWrapper show inWorkflow={false}>
<FeaturePanelDrawer show inWorkflow={false}>
<div data-testid="content">Content</div>
</DialogWrapper>,
</FeaturePanelDrawer>,
)
const content = screen.getByTestId('content')
const panel = content.parentElement
const layoutContainer = screen.getByTestId('dialog-layout-container')
const drawer = screen.getByRole('dialog')
const layoutContainer = screen.getByTestId('feature-panel-drawer-layout')
expect(layoutContainer).toHaveClass('pr-2')
expect(layoutContainer).toHaveClass('pt-[64px]')
expect(layoutContainer).not.toHaveClass('pt-[112px]')
expect(layoutContainer).toBeInTheDocument()
expect(panel).toHaveClass('rounded-2xl')
expect(panel).toHaveClass('border-[0.5px]')
expect(panel).not.toHaveClass('rounded-l-2xl')
expect(drawer).toHaveClass('data-[swipe-direction=right]:!top-[64px]')
expect(drawer).toHaveClass('data-[swipe-direction=right]:!right-2')
expect(drawer).toHaveClass('data-[swipe-direction=right]:!rounded-2xl')
expect(drawer).toHaveClass('data-[swipe-direction=right]:!border-[0.5px]')
expect(drawer).not.toHaveClass('data-[swipe-direction=right]:!rounded-l-2xl')
})
it('should accept custom className', () => {
render(
<DialogWrapper show className="custom-class">
<FeaturePanelDrawer show className="custom-class">
<div data-testid="content">Content</div>
</DialogWrapper>,
</FeaturePanelDrawer>,
)
const wrapper = screen.getByTestId('content').parentElement
expect(wrapper).toHaveClass('custom-class')
expect(screen.getByRole('dialog')).toHaveClass('custom-class')
})
})
@ -78,9 +77,9 @@ describe('DialogWrapper', () => {
const onClose = vi.fn()
render(
<DialogWrapper show onClose={onClose}>
<FeaturePanelDrawer show onClose={onClose}>
<div>Content</div>
</DialogWrapper>,
</FeaturePanelDrawer>,
)
fireEvent.keyDown(document, { key: 'Escape' })
@ -92,9 +91,9 @@ describe('DialogWrapper', () => {
it('should not throw when escape is pressed without onClose', () => {
render(
<DialogWrapper show>
<FeaturePanelDrawer show>
<div>Content</div>
</DialogWrapper>,
</FeaturePanelDrawer>,
)
expect(() => {

View File

@ -2,11 +2,11 @@
import type { FC } from 'react'
import type { AnnotationReplyConfig } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
@ -58,52 +58,61 @@ const ConfigParamModal: FC<Props> = ({ isShow, onHide: doHide, onSave, isInit, a
setLoading(false)
}
return (
<Modal isShow={isShow} onClose={onHide} className="!mt-14 !w-[640px] !max-w-none !p-6">
<div className="mb-2 title-2xl-semi-bold text-text-primary">
{t(`initSetup.${isInit ? 'title' : 'configTitle'}`, { ns: 'appAnnotation' })}
</div>
<Dialog
open={isShow}
onOpenChange={(open) => {
if (!open)
onHide()
}}
>
<DialogContent className="!mt-14 !w-[640px] !max-w-none overflow-hidden! border-none !p-6 text-left align-middle">
<div className="mt-6 space-y-3">
<Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}>
<ScoreSlider
className="mt-1"
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
onChange={(val) => {
setAnnotationConfig({
...annotationConfig,
score_threshold: val / 100,
})
}}
/>
</Item>
<div className="mb-2 title-2xl-semi-bold text-text-primary">
{t(`initSetup.${isInit ? 'title' : 'configTitle'}`, { ns: 'appAnnotation' })}
</div>
<Item title={t('modelProvider.embeddingModel.key', { ns: 'common' })} tooltip={t('embeddingModelSwitchTip', { ns: 'appAnnotation' })}>
<div className="pt-1">
<ModelSelector
defaultModel={embeddingModel && {
provider: embeddingModel.providerName,
model: embeddingModel.modelName,
}}
modelList={embeddingsModelList}
onSelect={(val) => {
setEmbeddingModel({
providerName: val.provider,
modelName: val.model,
<div className="mt-6 space-y-3">
<Item title={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })} tooltip={t('feature.annotation.scoreThreshold.description', { ns: 'appDebug' })}>
<ScoreSlider
className="mt-1"
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
onChange={(val) => {
setAnnotationConfig({
...annotationConfig,
score_threshold: val / 100,
})
}}
/>
</div>
</Item>
</div>
</Item>
<div className="mt-6 flex justify-end gap-2">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" onClick={handleSave} loading={isLoading}>
<div></div>
<div>{t(`initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`, { ns: 'appAnnotation' })}</div>
</Button>
</div>
</Modal>
<Item title={t('modelProvider.embeddingModel.key', { ns: 'common' })} tooltip={t('embeddingModelSwitchTip', { ns: 'appAnnotation' })}>
<div className="pt-1">
<ModelSelector
defaultModel={embeddingModel && {
provider: embeddingModel.providerName,
model: embeddingModel.modelName,
}}
modelList={embeddingsModelList}
onSelect={(val) => {
setEmbeddingModel({
providerName: val.provider,
modelName: val.model,
})
}}
/>
</div>
</Item>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button variant="primary" onClick={handleSave} loading={isLoading}>
<div></div>
<div>{t(`initSetup.${isInit ? 'confirmBtn' : 'configConfirmBtn'}`, { ns: 'appAnnotation' })}</div>
</Button>
</div>
</DialogContent>
</Dialog>
)
}
export default React.memo(ConfigParamModal)

View File

@ -1,57 +0,0 @@
import type { ReactNode } from 'react'
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { Fragment, useCallback } from 'react'
type DialogProps = {
className?: string
children: ReactNode
show: boolean
onClose?: () => void
inWorkflow?: boolean
}
const DialogWrapper = ({
className,
children,
show,
onClose,
inWorkflow = true,
}: DialogProps) => {
const close = useCallback(() => onClose?.(), [onClose])
return (
<Transition appear show={show} as={Fragment}>
<Dialog as="div" className="relative z-40" onClose={close}>
<TransitionChild>
<div className={cn(
'fixed inset-0 bg-black/25',
'data-closed:opacity-0',
'data-enter:opacity-100 data-enter:duration-300 data-enter:ease-out',
'data-leave:opacity-0 data-leave:duration-200 data-leave:ease-in',
)}
/>
</TransitionChild>
<div className="fixed inset-0">
<div className={cn('flex min-h-full flex-col items-end justify-center pb-2', inWorkflow ? 'pt-[112px]' : 'pt-[64px] pr-2')} data-testid="dialog-layout-container">
<TransitionChild>
<DialogPanel className={cn(
'relative flex h-0 w-[420px] grow flex-col overflow-hidden border-components-panel-border bg-components-panel-bg-alt p-0 text-left align-middle shadow-xl transition-all',
inWorkflow ? 'rounded-l-2xl border-t-[0.5px] border-b-[0.5px] border-l-[0.5px]' : 'rounded-2xl border-[0.5px]',
'data-closed:scale-95 data-closed:opacity-0',
'data-enter:scale-100 data-enter:opacity-100 data-enter:duration-300 data-enter:ease-out',
'data-leave:scale-95 data-leave:opacity-0 data-leave:duration-200 data-leave:ease-in',
className,
)}
>
{children}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
)
}
export default DialogWrapper

View File

@ -0,0 +1,60 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
DrawerBackdrop,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useCallback } from 'react'
type FeaturePanelDrawerProps = {
className?: string
children: ReactNode
show: boolean
onClose?: () => void
inWorkflow?: boolean
}
export function FeaturePanelDrawer({
className,
children,
show,
onClose,
inWorkflow = true,
}: FeaturePanelDrawerProps) {
const close = useCallback(() => onClose?.(), [onClose])
return (
<Drawer
open={show}
swipeDirection="right"
onOpenChange={(open) => {
if (!open)
close()
}}
>
<DrawerPortal>
<DrawerBackdrop className="bg-black/25" />
<DrawerViewport data-testid="feature-panel-drawer-layout">
<DrawerPopup
className={cn(
'border-components-panel-border bg-components-panel-bg-alt p-0 text-left align-middle',
'data-[swipe-direction=right]:!h-auto data-[swipe-direction=right]:!w-[420px] data-[swipe-direction=right]:!max-w-[calc(100vw-2rem)]',
inWorkflow
? 'data-[swipe-direction=right]:!top-[112px] data-[swipe-direction=right]:!right-0 data-[swipe-direction=right]:!bottom-2 data-[swipe-direction=right]:!rounded-l-2xl data-[swipe-direction=right]:!rounded-r-none data-[swipe-direction=right]:!border-t-[0.5px] data-[swipe-direction=right]:!border-r-0 data-[swipe-direction=right]:!border-b-[0.5px] data-[swipe-direction=right]:!border-l-[0.5px]'
: 'data-[swipe-direction=right]:!top-[64px] data-[swipe-direction=right]:!right-2 data-[swipe-direction=right]:!bottom-2 data-[swipe-direction=right]:!rounded-2xl data-[swipe-direction=right]:!border-[0.5px]',
className,
)}
>
<DrawerContent className="flex min-h-0 flex-1 touch-auto flex-col overflow-hidden p-0 pb-0">
{children}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}

View File

@ -1,13 +1,13 @@
import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
import { RiCloseLine } from '@remixicon/react'
import { DrawerCloseButton } from '@langgenius/dify-ui/drawer'
import { useTranslation } from 'react-i18next'
import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply'
import Citation from '@/app/components/base/features/new-feature-panel/citation'
import ConversationOpener from '@/app/components/base/features/new-feature-panel/conversation-opener'
import DialogWrapper from '@/app/components/base/features/new-feature-panel/dialog-wrapper'
import { FeaturePanelDrawer } from '@/app/components/base/features/new-feature-panel/feature-panel-drawer'
import FileUpload from '@/app/components/base/features/new-feature-panel/file-upload'
import FollowUp from '@/app/components/base/features/new-feature-panel/follow-up'
import ImageUpload from '@/app/components/base/features/new-feature-panel/image-upload'
@ -48,7 +48,7 @@ const NewFeaturePanel = ({
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
return (
<DialogWrapper
<FeaturePanelDrawer
show={show}
onClose={onClose}
inWorkflow={inWorkflow}
@ -60,7 +60,10 @@ const NewFeaturePanel = ({
<div className="system-xl-semibold text-text-primary">{t('common.features', { ns: 'workflow' })}</div>
<div className="body-xs-regular text-text-tertiary">{t('common.featuresDescription', { ns: 'workflow' })}</div>
</div>
<div className="h-8 w-8 cursor-pointer p-2" onClick={onClose}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
<DrawerCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="h-8 w-8 p-2"
/>
</div>
{/* list */}
<div className="grow basis-0 overflow-y-auto px-4 pb-4">
@ -96,7 +99,7 @@ const NewFeaturePanel = ({
)}
</div>
</div>
</DialogWrapper>
</FeaturePanelDrawer>
)
}

View File

@ -3,12 +3,11 @@ import type { CodeBasedExtensionItem } from '@/models/common'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Modal from '@/app/components/base/modal'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@ -226,165 +225,164 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
}
return (
<Modal
isShow
onClose={noop}
className="mt-14! w-[600px]! max-w-none! p-6!"
>
<div className="flex items-center justify-between">
<div className="title-2xl-semi-bold text-text-primary">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div>
<div
role="button"
tabIndex={0}
className="cursor-pointer p-1"
onClick={onCancel}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onCancel()
}
}}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
<Dialog open>
<DialogContent className="mt-14! w-[600px]! max-w-none! overflow-hidden! border-none p-6! text-left align-middle">
<div className="flex items-center justify-between">
<div className="title-2xl-semi-bold text-text-primary">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div>
<div
role="button"
tabIndex={0}
className="cursor-pointer p-1"
onClick={onCancel}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onCancel()
}
}}
>
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</div>
</div>
</div>
<div className="py-2">
<div className="text-sm leading-9 font-medium text-text-primary">
{t('feature.moderation.modal.provider.title', { ns: 'appDebug' })}
</div>
<div className="grid grid-cols-3 gap-2.5">
{
providers.map(provider => (
<div
key={provider.key}
className={cn(
'flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
localeData.type !== provider.key && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs',
localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled',
)}
onClick={() => handleDataTypeChange(provider.key)}
>
<div className={cn(
'mr-2 h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs',
localeData.type === provider.key && 'border-[5px] border-components-radio-border-checked',
)}
<div className="py-2">
<div className="text-sm leading-9 font-medium text-text-primary">
{t('feature.moderation.modal.provider.title', { ns: 'appDebug' })}
</div>
<div className="grid grid-cols-3 gap-2.5">
{
providers.map(provider => (
<div
key={provider.key}
className={cn(
'flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
localeData.type !== provider.key && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg system-sm-medium shadow-xs',
localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled',
)}
onClick={() => handleDataTypeChange(provider.key)}
>
<div className={cn(
'mr-2 h-4 w-4 rounded-full border border-components-radio-border bg-components-radio-bg shadow-xs',
localeData.type === provider.key && 'border-[5px] border-components-radio-border-checked',
)}
>
</div>
{provider.name}
</div>
))
}
</div>
{
!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
<div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2">
<span className="mr-1 i-custom-vender-line-general-info-circle h-4 w-4 text-[#F79009]" />
<div className="flex items-center text-xs font-medium text-gray-700">
{t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })}
<span
className="cursor-pointer text-primary-600"
onClick={handleOpenSettingsModal}
>
&nbsp;
{t('settings.provider', { ns: 'common' })}
&nbsp;
</span>
{t('feature.moderation.modal.openaiNotConfig.after', { ns: 'appDebug' })}
</div>
{provider.name}
</div>
))
)
}
</div>
{
!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
<div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2">
<span className="mr-1 i-custom-vender-line-general-info-circle h-4 w-4 text-[#F79009]" />
<div className="flex items-center text-xs font-medium text-gray-700">
{t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })}
<span
className="cursor-pointer text-primary-600"
onClick={handleOpenSettingsModal}
>
&nbsp;
{t('settings.provider', { ns: 'common' })}
&nbsp;
</span>
{t('feature.moderation.modal.openaiNotConfig.after', { ns: 'appDebug' })}
localeData.type === 'keywords' && (
<div className="py-2">
<div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div>
<div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div>
<div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2">
<textarea
value={localeData.config?.keywords || ''}
onChange={handleDataKeywordsChange}
className="block h-full w-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''}
/>
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
<span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span>
/
<span className="text-text-tertiary">
100
{t('feature.moderation.modal.keywords.line', { ns: 'appDebug' })}
</span>
</div>
</div>
</div>
)
}
</div>
{
localeData.type === 'keywords' && (
<div className="py-2">
<div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div>
<div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div>
<div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2">
<textarea
value={localeData.config?.keywords || ''}
onChange={handleDataKeywordsChange}
className="block h-full w-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''}
/>
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
<span>{(localeData.config?.keywords || '').split('\n').filter(Boolean).length}</span>
/
<span className="text-text-tertiary">
100
{t('feature.moderation.modal.keywords.line', { ns: 'appDebug' })}
</span>
{
localeData.type === 'api' && (
<div className="py-2">
<div className="flex h-9 items-center justify-between">
<div className="text-sm font-medium text-text-primary">{t('apiBasedExtension.selector.title', { ns: 'common' })}</div>
<a
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center text-xs text-text-tertiary hover:text-primary-600"
>
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
/>
</div>
</div>
)
}
{
localeData.type === 'api' && (
<div className="py-2">
<div className="flex h-9 items-center justify-between">
<div className="text-sm font-medium text-text-primary">{t('apiBasedExtension.selector.title', { ns: 'common' })}</div>
<a
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center text-xs text-text-tertiary hover:text-primary-600"
>
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
<ApiBasedExtensionSelector
value={localeData.config?.api_based_extension_id || ''}
onChange={handleDataApiBasedChange}
)
}
{
systemTypes.findIndex(t => t === localeData.type) < 0
&& currentProvider?.form_schema
&& (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
/>
</div>
)
}
{
systemTypes.findIndex(t => t === localeData.type) < 0
&& currentProvider?.form_schema
&& (
<FormGeneration
forms={currentProvider?.form_schema}
value={localeData.config}
onChange={handleDataExtraChange}
/>
)
}
<Divider bgStyle="gradient" className="my-3 h-px" />
<ModerationContent
title={t('feature.moderation.modal.content.input', { ns: 'appDebug' }) || ''}
config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('inputs_config', config)}
info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''}
showPreset={localeData.type !== 'api'}
/>
<ModerationContent
title={t('feature.moderation.modal.content.output', { ns: 'appDebug' }) || ''}
config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('outputs_config', config)}
info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''}
showPreset={localeData.type !== 'api'}
/>
<div className="mt-1 mb-8 text-xs font-medium text-text-tertiary">{t('feature.moderation.modal.content.condition', { ns: 'appDebug' })}</div>
<div className="flex items-center justify-end">
<Button
onClick={onCancel}
className="mr-2"
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</Modal>
)
}
<Divider bgStyle="gradient" className="my-3 h-px" />
<ModerationContent
title={t('feature.moderation.modal.content.input', { ns: 'appDebug' }) || ''}
config={localeData.config?.inputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('inputs_config', config)}
info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''}
showPreset={localeData.type !== 'api'}
/>
<ModerationContent
title={t('feature.moderation.modal.content.output', { ns: 'appDebug' }) || ''}
config={localeData.config?.outputs_config || { enabled: false, preset_response: '' }}
onConfigChange={config => handleDataContentChange('outputs_config', config)}
info={(localeData.type === 'api' && t('feature.moderation.modal.content.fromApi', { ns: 'appDebug' })) || ''}
showPreset={localeData.type !== 'api'}
/>
<div className="mt-1 mb-8 text-xs font-medium text-text-tertiary">{t('feature.moderation.modal.content.condition', { ns: 'appDebug' })}</div>
<div className="flex items-center justify-end">
<Button
onClick={onCancel}
className="mr-2"
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -39,15 +39,14 @@ describe('AudioPreview', () => {
expect(onCancel).toHaveBeenCalled()
})
it('should stop propagation when backdrop is clicked', () => {
const { baseElement } = render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
it('should not close when backdrop is clicked', () => {
const onCancel = vi.fn()
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />)
const backdrop = baseElement.querySelector('[tabindex="-1"]')
const event = new MouseEvent('click', { bubbles: true })
const stopPropagation = vi.spyOn(event, 'stopPropagation')
backdrop!.dispatchEvent(event)
const dialog = screen.getByRole('dialog')
fireEvent.click(dialog)
expect(stopPropagation).toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('should call onCancel when Escape key is pressed', () => {
@ -64,6 +63,6 @@ describe('AudioPreview', () => {
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
const audio = document.querySelector('audio')
expect(audio?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
expect(audio?.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body)
})
})

View File

@ -35,7 +35,7 @@ describe('PdfPreview', () => {
}
const getControl = (rightClass: 'right-24' | 'right-16' | 'right-6') => {
const control = document.querySelector(`div.absolute.${rightClass}.top-6`) as HTMLDivElement | null
const control = document.querySelector(`button.absolute.${rightClass}.top-6`) as HTMLButtonElement | null
expect(control).toBeInTheDocument()
return control!
}
@ -129,14 +129,12 @@ describe('PdfPreview', () => {
expect(mockOnCancel).toHaveBeenCalled()
})
it('should render the overlay and stop click propagation', () => {
it('should render the overlay and keep backdrop clicks from closing', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
const overlay = document.querySelector('[tabindex="-1"]')
const overlay = screen.getByRole('dialog')
expect(overlay).toBeInTheDocument()
const event = new MouseEvent('click', { bubbles: true })
const stopPropagation = vi.spyOn(event, 'stopPropagation')
overlay!.dispatchEvent(event)
expect(stopPropagation).toHaveBeenCalled()
fireEvent.click(overlay)
expect(mockOnCancel).not.toHaveBeenCalled()
})
})

View File

@ -1,4 +1,4 @@
import { fireEvent, render } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import VideoPreview from '../video-preview'
describe('VideoPreview', () => {
@ -39,15 +39,14 @@ describe('VideoPreview', () => {
expect(onCancel).toHaveBeenCalled()
})
it('should stop propagation when backdrop is clicked', () => {
const { baseElement } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
it('should not close when backdrop is clicked', () => {
const onCancel = vi.fn()
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />)
const backdrop = baseElement.querySelector('[tabindex="-1"]')
const event = new MouseEvent('click', { bubbles: true })
const stopPropagation = vi.spyOn(event, 'stopPropagation')
backdrop!.dispatchEvent(event)
const dialog = screen.getByRole('dialog')
fireEvent.click(dialog)
expect(stopPropagation).toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('should call onCancel when Escape key is pressed', () => {
@ -64,6 +63,6 @@ describe('VideoPreview', () => {
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
const video = document.querySelector('video')
expect(video?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
expect(video?.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body)
})
})

View File

@ -1,8 +1,5 @@
import type { FC } from 'react'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
type AudioPreviewProps = {
url: string
@ -14,31 +11,40 @@ const AudioPreview: FC<AudioPreviewProps> = ({
title,
onCancel,
}) => {
useHotkeys('esc', onCancel)
return createPortal(
<div
className="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8"
onClick={e => e.stopPropagation()}
tabIndex={-1}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
disablePointerDismissal
>
<div>
<audio controls title={title} autoPlay={false} preload="metadata">
<source
type="audio/mpeg"
src={url}
className="max-h-full max-w-full"
/>
</audio>
</div>
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
<DialogContent
className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!"
backdropClassName="bg-transparent!"
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-btn" />
</div>
</div>,
document.body,
<div
aria-label={title}
tabIndex={-1}
onClick={e => e.stopPropagation()}
>
<audio controls title={title} autoPlay={false} preload="metadata">
<source
type="audio/mpeg"
src={url}
className="max-h-full max-w-full"
/>
</audio>
</div>
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-btn" />
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -186,7 +186,7 @@ describe('FileInAttachmentItem', () => {
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0]!)
// ImagePreview renders via createPortal with class "image-preview-container"
// ImagePreview renders through Dialog with class "image-preview-container"
const previewContainer = document.querySelector('.image-preview-container')!
expect(previewContainer)!.toBeInTheDocument()

View File

@ -95,8 +95,7 @@ describe('FileImageItem', () => {
const img = screen.getByRole('img')
fireEvent.click(img.parentElement!)
// ImagePreview renders via createPortal with class "image-preview-container", not role="dialog"
// ImagePreview renders via createPortal with class "image-preview-container", not role="dialog"
// ImagePreview renders through Dialog with class "image-preview-container"
expect(document.querySelector('.image-preview-container'))!.toBeInTheDocument()
})
@ -114,7 +113,7 @@ describe('FileImageItem', () => {
const img = screen.getByRole('img')
fireEvent.click(img.parentElement!)
// ImagePreview renders via createPortal with class "image-preview-container"
// ImagePreview renders through Dialog with class "image-preview-container"
const previewContainer = document.querySelector('.image-preview-container')!
expect(previewContainer)!.toBeInTheDocument()

View File

@ -1,12 +1,11 @@
import type { FC } from 'react'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { t } from 'i18next'
import * as React from 'react'
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { PdfHighlighter, PdfLoader } from './pdf-highlighter-adapter'
@ -20,6 +19,7 @@ const PdfPreview: FC<PdfPreviewProps> = ({
url,
onCancel,
}) => {
const { t } = useTranslation()
const media = useBreakpoints()
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
@ -42,87 +42,106 @@ const PdfPreview: FC<PdfPreviewProps> = ({
})
}
useHotkeys('esc', onCancel)
useHotkeys('up', zoomIn)
useHotkeys('down', zoomOut)
return createPortal(
<div
className={`fixed inset-0 z-1000 flex items-center justify-center bg-black/80 ${!isMobile && 'p-8'}`}
onClick={e => e.stopPropagation()}
tabIndex={-1}
const zoomOutLabel = t('operation.zoomOut', { ns: 'common' })
const zoomInLabel = t('operation.zoomIn', { ns: 'common' })
const cancelLabel = t('operation.cancel', { ns: 'common' })
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
disablePointerDismissal
>
<div
className="h-[95vh] max-h-full w-screen max-w-full overflow-hidden"
style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
<DialogContent
className={`inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 shadow-none! ${!isMobile ? 'p-8!' : 'p-0!'}`}
backdropClassName="bg-transparent!"
>
<PdfLoader
workerSrc="/pdf.worker.min.mjs"
url={url}
beforeLoad={<div className="flex h-64 items-center justify-center"><Loading type="app" /></div>}
<div
aria-label={url}
tabIndex={-1}
onClick={e => e.stopPropagation()}
className="h-[95vh] max-h-full w-screen max-w-full overflow-hidden"
style={{ transform: `scale(${scale})`, transformOrigin: 'center', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{(pdfDocument) => {
return (
<PdfHighlighter
pdfDocument={pdfDocument}
enableAreaSelection={event => event.altKey}
scrollRef={noop}
onScrollChange={noop}
onSelectionFinished={() => null}
highlightTransform={() => { return <div /> }}
highlights={[]}
/>
)
}}
</PdfLoader>
</div>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomOut}
>
<RiZoomOutLine className="h-4 w-4 text-gray-500" />
</div>
)}
/>
<TooltipContent>
{t('operation.zoomOut', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomIn}
>
<RiZoomInLine className="h-4 w-4 text-gray-500" />
</div>
)}
/>
<TooltipContent>
{t('operation.zoomIn', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
onClick={onCancel}
>
<RiCloseLine className="h-4 w-4 text-gray-500" />
</div>
)}
/>
<TooltipContent>
{t('operation.cancel', { ns: 'common' })}
</TooltipContent>
</Tooltip>
</div>,
document.body,
<PdfLoader
workerSrc="/pdf.worker.min.mjs"
url={url}
beforeLoad={<div className="flex h-64 items-center justify-center"><Loading type="app" /></div>}
>
{(pdfDocument) => {
return (
<PdfHighlighter
pdfDocument={pdfDocument}
enableAreaSelection={event => event.altKey}
scrollRef={noop}
onScrollChange={noop}
onSelectionFinished={() => null}
highlightTransform={() => { return <div /> }}
highlights={[]}
/>
)
}}
</PdfLoader>
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={zoomOutLabel}
className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomOut}
>
<RiZoomOutLine className="h-4 w-4 text-gray-500" />
</button>
)}
/>
<TooltipContent>
{zoomOutLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={zoomInLabel}
className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomIn}
>
<RiZoomInLine className="h-4 w-4 text-gray-500" />
</button>
)}
/>
<TooltipContent>
{zoomInLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={cancelLabel}
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
onClick={onCancel}
>
<RiCloseLine className="h-4 w-4 text-gray-500" />
</button>
)}
/>
<TooltipContent>
{cancelLabel}
</TooltipContent>
</Tooltip>
</DialogContent>
</Dialog>
)
}

View File

@ -1,7 +1,5 @@
import type { FC } from 'react'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
type VideoPreviewProps = {
url: string
@ -13,31 +11,40 @@ const VideoPreview: FC<VideoPreviewProps> = ({
title,
onCancel,
}) => {
useHotkeys('esc', onCancel)
return createPortal(
<div
className="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8"
onClick={e => e.stopPropagation()}
tabIndex={-1}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
disablePointerDismissal
>
<div>
<video controls title={title} autoPlay={false} preload="metadata">
<source
type="video/mp4"
src={url}
className="max-h-full max-w-full"
/>
</video>
</div>
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
<DialogContent
className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!"
backdropClassName="bg-transparent!"
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="video-preview-close-btn" />
</div>
</div>,
document.body,
<div
aria-label={title}
tabIndex={-1}
onClick={e => e.stopPropagation()}
>
<video controls title={title} autoPlay={false} preload="metadata">
<source
type="video/mp4"
src={url}
className="max-h-full max-w-full"
/>
</video>
</div>
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="video-preview-close-btn" />
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -153,10 +153,11 @@ describe('FloatRightContainer', () => {
)
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveClass('custom-dialog-class')
expect(document.querySelector('.custom-dialog-class')).toBeInTheDocument()
const panel = document.querySelector('.custom-panel-class')
expect(panel).toBeInTheDocument()
expect(dialog).toHaveClass('custom-panel-class')
})
})

View File

@ -1,214 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import FullScreenModal from '../index'
describe('FullScreenModal Component', () => {
it('should not render anything when open is false', () => {
render(
<FullScreenModal open={false}>
<div data-testid="modal-content">Content</div>
</FullScreenModal>,
)
expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument()
})
it('should render content when open is true', async () => {
render(
<FullScreenModal open={true}>
<div data-testid="modal-content">Content</div>
</FullScreenModal>,
)
expect(await screen.findByTestId('modal-content')).toBeInTheDocument()
})
it('should not crash when provided with title and description props', async () => {
await act(async () => {
render(
<FullScreenModal
open={true}
title="My Title"
description="My Description"
>
Content
</FullScreenModal>,
)
})
})
describe('Props Handling', () => {
it('should apply wrapperClassName to the dialog root', async () => {
render(
<FullScreenModal
open={true}
wrapperClassName="custom-wrapper-class"
>
Content
</FullScreenModal>,
)
await screen.findByRole('dialog')
const element = document.querySelector('.custom-wrapper-class')
expect(element).toBeInTheDocument()
expect(element).toHaveClass('relative', 'z-50')
})
it('should apply className to the inner panel', async () => {
await act(async () => {
render(
<FullScreenModal
open={true}
className="custom-panel-class"
>
Content
</FullScreenModal>,
)
})
const panel = document.querySelector('.custom-panel-class')
expect(panel).toBeInTheDocument()
expect(panel).toHaveClass('h-full')
})
it('should handle overflowVisible prop', async () => {
const { rerender } = await act(async () => {
return render(
<FullScreenModal
open={true}
overflowVisible={true}
className="target-panel"
>
Content
</FullScreenModal>,
)
})
let panel = document.querySelector('.target-panel')
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
await act(async () => {
rerender(
<FullScreenModal
open={true}
overflowVisible={false}
className="target-panel"
>
Content
</FullScreenModal>,
)
})
panel = document.querySelector('.target-panel')
expect(panel).toHaveClass('overflow-hidden')
expect(panel).not.toHaveClass('overflow-visible')
})
it('should render close button when closable is true', async () => {
await act(async () => {
render(
<FullScreenModal open={true} closable={true}>
Content
</FullScreenModal>,
)
})
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
expect(closeButton).toBeInTheDocument()
})
it('should not render close button when closable is false', async () => {
await act(async () => {
render(
<FullScreenModal open={true} closable={false}>
Content
</FullScreenModal>,
)
})
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
expect(closeButton).not.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onClose when close button is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<FullScreenModal open={true} closable={true} onClose={onClose}>
Content
</FullScreenModal>,
)
const closeBtn = document.querySelector('.bg-components-button-tertiary-bg')
expect(closeBtn).toBeInTheDocument()
await user.click(closeBtn!)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when clicking the backdrop', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<FullScreenModal open={true} onClose={onClose}>
<div data-testid="inner">Content</div>
</FullScreenModal>,
)
const dialog = document.querySelector('.relative.z-50')
if (dialog) {
await user.click(dialog)
expect(onClose).toHaveBeenCalled()
}
else {
throw new Error('Dialog root not found')
}
})
it('should call onClose when Escape key is pressed', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<FullScreenModal open={true} onClose={onClose}>
Content
</FullScreenModal>,
)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('should not call onClose when clicking inside the content', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<FullScreenModal open={true} onClose={onClose}>
<div className="bg-background-default-subtle">
<button>Action</button>
</div>
</FullScreenModal>,
)
const innerButton = screen.getByRole('button', { name: 'Action' })
await user.click(innerButton)
expect(onClose).not.toHaveBeenCalled()
const contentPanel = document.querySelector('.bg-background-default-subtle')
await act(async () => {
fireEvent.click(contentPanel!)
})
expect(onClose).not.toHaveBeenCalled()
})
})
describe('Default Props', () => {
it('should not throw if onClose is not provided', async () => {
const user = userEvent.setup()
render(<FullScreenModal open={true} closable={true}>Content</FullScreenModal>)
const closeButton = document.querySelector('.bg-components-button-tertiary-bg')
await user.click(closeButton!)
})
})
})

View File

@ -1,59 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import FullScreenModal from '.'
const meta = {
title: 'Base/Feedback/FullScreenModal',
component: FullScreenModal,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Backdrop-blurred fullscreen modal. Supports close button, custom content, and optional overflow visibility.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FullScreenModal>
export default meta
type Story = StoryObj<typeof meta>
const ModalDemo = (props: React.ComponentProps<typeof FullScreenModal>) => {
const [open, setOpen] = useState(false)
return (
<div className="flex h-[360px] items-center justify-center bg-background-default-subtle">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Launch full-screen modal
</button>
<FullScreenModal
{...props}
open={open}
onClose={() => setOpen(false)}
closable
>
<div className="flex h-full flex-col bg-background-default-subtle">
<div className="flex h-16 items-center justify-center border-b border-divider-subtle text-lg font-semibold text-text-primary">
Full-screen experience
</div>
<div className="flex flex-1 items-center justify-center text-sm text-text-secondary">
Place dashboards, flow builders, or immersive previews here.
</div>
</div>
</FullScreenModal>
</div>
)
}
export const Playground: Story = {
render: args => <ModalDemo {...args} />,
args: {
open: false,
},
}

View File

@ -1,65 +0,0 @@
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLargeLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
type IModal = {
className?: string
wrapperClassName?: string
open: boolean
onClose?: () => void
title?: React.ReactNode
description?: React.ReactNode
children?: React.ReactNode
closable?: boolean
overflowVisible?: boolean
}
export default function FullScreenModal({
className,
wrapperClassName,
open,
onClose = noop,
children,
closable = false,
overflowVisible = false,
}: IModal) {
return (
<Transition show={open} appear>
<Dialog as="div" className={cn('relative z-50', wrapperClassName)} onClose={onClose}>
<TransitionChild>
<div className={cn('fixed inset-0 bg-background-overlay-backdrop backdrop-blur-[6px]', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')} />
</TransitionChild>
<div
className="fixed inset-0 h-screen w-screen p-4"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<div className="relative h-full w-full rounded-2xl border border-effects-highlight bg-background-default-subtle">
<TransitionChild>
<DialogPanel className={cn('h-full', overflowVisible ? 'overflow-visible' : 'overflow-hidden', 'duration-100 ease-in data-closed:scale-95 data-closed:opacity-0', 'data-enter:scale-100 data-enter:opacity-100', 'data-enter:scale-95 data-leave:opacity-0', className)}>
{closable
&& (
<div
className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center
rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover"
onClick={(e) => {
e.stopPropagation()
onClose()
}}
>
<RiCloseLargeLine className="h-3.5 w-3.5 text-components-button-tertiary-text" />
</div>
)}
{children}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
)
}

View File

@ -113,7 +113,7 @@ describe('ImageGallery', () => {
await user.click(getImages(container)[0]!)
expect(screen.queryByTestId('image-preview-container'))!.toBeInTheDocument()
await user.keyboard('{Escape}')
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
await waitFor(() => {
expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument()

View File

@ -43,7 +43,7 @@ describe('AudioPreview', () => {
render(<AudioPreview {...defaultProps} />)
const overlay = screen.getByTestId('audio-preview-overlay')
expect(overlay).toBeInTheDocument()
expect(overlay.parentElement).toBe(document.body)
expect(overlay.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body)
})
})

View File

@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ImagePreview from '../image-preview'
@ -89,7 +89,7 @@ describe('ImagePreview', () => {
const overlay = getOverlay()
expect(overlay).toBeInTheDocument()
expect(overlay?.parentElement).toBe(document.body)
expect(overlay.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body)
expect(screen.getByRole('img', { name: 'Preview Image' })).toHaveAttribute('src', 'https://example.com/image.png')
})
@ -108,7 +108,6 @@ describe('ImagePreview', () => {
describe('Hotkeys', () => {
it('should trigger esc/left/right handlers from keyboard', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
const onPrev = vi.fn()
const onNext = vi.fn()
@ -122,7 +121,9 @@ describe('ImagePreview', () => {
/>,
)
await user.keyboard('{Escape}{ArrowLeft}{ArrowRight}')
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
fireEvent.keyDown(document, { key: 'ArrowLeft', code: 'ArrowLeft' })
fireEvent.keyDown(document, { key: 'ArrowRight', code: 'ArrowRight' })
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onPrev).toHaveBeenCalledTimes(1)
@ -130,7 +131,6 @@ describe('ImagePreview', () => {
})
it('should zoom in and out from keyboard up/down hotkeys', async () => {
const user = userEvent.setup()
render(
<ImagePreview
url="https://example.com/image.png"
@ -140,12 +140,12 @@ describe('ImagePreview', () => {
)
const image = screen.getByRole('img', { name: 'Preview Image' })
await user.keyboard('{ArrowUp}')
fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' })
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1.2) translate(0px, 0px)' })
})
await user.keyboard('{ArrowDown}')
fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown' })
await waitFor(() => {
expect(image).toHaveStyle({ transform: 'scale(1) translate(0px, 0px)' })
})

View File

@ -51,7 +51,7 @@ describe('VideoPreview', () => {
const overlay = getOverlay()
expect(overlay).toBeInTheDocument()
expect(overlay.parentElement).toBe(document.body)
expect(overlay.closest('[data-base-ui-portal]')?.parentElement).toBe(document.body)
})
})

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import { createPortal } from 'react-dom'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
type AudioPreviewProps = {
url: string
@ -11,26 +11,42 @@ const AudioPreview: FC<AudioPreviewProps> = ({
title,
onCancel,
}) => {
return createPortal(
<div className="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8" onClick={e => e.stopPropagation()} data-testid="audio-preview-overlay">
<div>
<audio controls title={title} autoPlay={false} preload="metadata" data-testid="audio-element">
<source
type="audio/mpeg"
src={url}
className="max-h-full max-w-full"
/>
</audio>
</div>
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
data-testid="close-preview"
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
disablePointerDismissal
>
<DialogContent
className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!"
backdropClassName="bg-transparent!"
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" />
</div>
</div>,
document.body,
<div
aria-label={title}
data-testid="audio-preview-overlay"
tabIndex={-1}
onClick={e => e.stopPropagation()}
>
<audio controls title={title} autoPlay={false} preload="metadata" data-testid="audio-element">
<source
type="audio/mpeg"
src={url}
className="max-h-full max-w-full"
/>
</audio>
</div>
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
data-testid="close-preview"
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" />
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,12 +1,12 @@
import type { FC } from 'react'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { noop } from 'es-toolkit/function'
import { t } from 'i18next'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import { downloadUrl } from '@/utils/download'
type ImagePreviewProps = {
@ -33,6 +33,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
onPrev,
onNext,
}) => {
const { t } = useTranslation()
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
@ -119,7 +120,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
}
}
shareImage()
}, [title, url])
}, [t, title, url])
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
if (e.deltaY < 0)
@ -167,131 +168,161 @@ const ImagePreview: FC<ImagePreviewProps> = ({
}
}, [handleMouseUp])
useHotkeys('esc', onCancel)
useHotkeys('up', zoomIn)
useHotkeys('down', zoomOut)
useHotkeys('left', onPrev || noop)
useHotkeys('right', onNext || noop)
return createPortal(
<div
className="image-preview-container fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8"
onClick={e => e.stopPropagation()}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor: scale > 1 ? 'move' : 'default' }}
tabIndex={-1}
data-testid="image-preview-container"
const copyImageLabel = t('operation.copyImage', { ns: 'common' })
const zoomOutLabel = t('operation.zoomOut', { ns: 'common' })
const zoomInLabel = t('operation.zoomIn', { ns: 'common' })
const downloadLabel = t('operation.download', { ns: 'common' })
const openInNewTabLabel = t('operation.openInNewTab', { ns: 'common' })
const cancelLabel = t('operation.cancel', { ns: 'common' })
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
disablePointerDismissal
>
{ }
{ }
<img
ref={imgRef}
alt={title}
src={isBase64(url) ? `data:image/png;base64,${url}` : url}
className="max-h-full max-w-full"
style={{
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease-in-out',
}}
data-testid="image-preview-image"
/>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-48 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={imageCopy}
>
{isCopied
? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" data-testid="image-preview-copied-icon" />
: <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" data-testid="image-preview-copy-button" />}
</div>
)}
/>
<TooltipContent>
{t('operation.copyImage', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomOut}
>
<span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-out-button" />
</div>
)}
/>
<TooltipContent>
{t('operation.zoomOut', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-32 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomIn}
>
<span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-in-button" />
</div>
)}
/>
<TooltipContent>
{t('operation.zoomIn', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={downloadImage}
>
<span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" data-testid="image-preview-download-button" />
</div>
)}
/>
<TooltipContent>
{t('operation.download', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={openInNewTab}
>
<span className="i-ri-add-box-line h-4 w-4 text-gray-500" data-testid="image-preview-open-in-tab-button" />
</div>
)}
/>
<TooltipContent>
{t('operation.openInNewTab', { ns: 'common' })}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
onClick={onCancel}
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" />
</div>
)}
/>
<TooltipContent>
{t('operation.cancel', { ns: 'common' })}
</TooltipContent>
</Tooltip>
</div>,
document.body,
<DialogContent
className="image-preview-container inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!"
backdropClassName="bg-transparent!"
>
<div
aria-label={title}
data-testid="image-preview-container"
tabIndex={-1}
className="flex h-full w-full items-center justify-center"
onClick={e => e.stopPropagation()}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor: scale > 1 ? 'move' : 'default' }}
>
<img
ref={imgRef}
alt={title}
src={isBase64(url) ? `data:image/png;base64,${url}` : url}
className="max-h-full max-w-full"
style={{
transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease-in-out',
}}
data-testid="image-preview-image"
/>
</div>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={copyImageLabel}
className="absolute top-6 right-48 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={imageCopy}
>
{isCopied
? <span className="i-ri-file-copy-line h-4 w-4 text-green-500" data-testid="image-preview-copied-icon" />
: <span className="i-ri-file-copy-line h-4 w-4 text-gray-500" data-testid="image-preview-copy-button" />}
</button>
)}
/>
<TooltipContent>
{copyImageLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={zoomOutLabel}
className="absolute top-6 right-40 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomOut}
>
<span className="i-ri-zoom-out-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-out-button" />
</button>
)}
/>
<TooltipContent>
{zoomOutLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={zoomInLabel}
className="absolute top-6 right-32 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={zoomIn}
>
<span className="i-ri-zoom-in-line h-4 w-4 text-gray-500" data-testid="image-preview-zoom-in-button" />
</button>
)}
/>
<TooltipContent>
{zoomInLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={downloadLabel}
className="absolute top-6 right-24 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={downloadImage}
>
<span className="i-ri-download-cloud-2-line h-4 w-4 text-gray-500" data-testid="image-preview-download-button" />
</button>
)}
/>
<TooltipContent>
{downloadLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={openInNewTabLabel}
className="absolute top-6 right-16 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={openInNewTab}
>
<span className="i-ri-add-box-line h-4 w-4 text-gray-500" data-testid="image-preview-open-in-tab-button" />
</button>
)}
/>
<TooltipContent>
{openInNewTabLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={cancelLabel}
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
onClick={onCancel}
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" />
</button>
)}
/>
<TooltipContent>
{cancelLabel}
</TooltipContent>
</Tooltip>
</DialogContent>
</Dialog>
)
}

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import { createPortal } from 'react-dom'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
type VideoPreviewProps = {
url: string
@ -11,25 +11,41 @@ const VideoPreview: FC<VideoPreviewProps> = ({
title,
onCancel,
}) => {
return createPortal(
<div className="fixed inset-0 z-1000 flex items-center justify-center bg-black/80 p-8" onClick={e => e.stopPropagation()} data-testid="video-preview">
<div>
<video controls title={title} autoPlay={false} preload="metadata" data-testid="video-element">
<source
type="video/mp4"
src={url}
className="max-h-full max-w-full"
/>
</video>
</div>
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
disablePointerDismissal
>
<DialogContent
className="inset-0! top-0! left-0! flex h-dvh! max-h-none! w-screen! max-w-none! translate-x-0! translate-y-0! items-center justify-center overflow-hidden! rounded-none! border-none! bg-black/80 p-8! shadow-none!"
backdropClassName="bg-transparent!"
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-button" />
</div>
</div>,
document.body,
<div
aria-label={title}
data-testid="video-preview"
tabIndex={-1}
onClick={e => e.stopPropagation()}
>
<video controls title={title} autoPlay={false} preload="metadata" data-testid="video-element">
<source
type="video/mp4"
src={url}
className="max-h-full max-w-full"
/>
</video>
</div>
<div
className="absolute top-6 right-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
>
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-button" />
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,84 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import ModalLikeWrap from '..'
describe('ModalLikeWrap', () => {
const defaultProps = {
title: 'Test Title',
onClose: vi.fn(),
onConfirm: vi.fn(),
children: <div>Test Content</div>,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Render', () => {
it('renders title and content correctly', () => {
render(<ModalLikeWrap {...defaultProps} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('renders beforeHeader if provided', () => {
const beforeHeader = <div data-testid="before-header">Before Header</div>
render(<ModalLikeWrap {...defaultProps} beforeHeader={beforeHeader} />)
expect(screen.getByTestId('before-header')).toBeInTheDocument()
expect(screen.getByText('Before Header')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls onClose when close icon is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const closeBtn = screen.getByTestId('modal-close-btn')
expect(closeBtn).toBeInTheDocument()
await act(async () => {
fireEvent.click(closeBtn)
})
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('calls onClose when Cancel button is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const cancelBtn = screen.getByText('common.operation.cancel')
await act(async () => {
fireEvent.click(cancelBtn)
})
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('calls onConfirm when Save button is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const saveBtn = screen.getByText('common.operation.save')
await act(async () => {
fireEvent.click(saveBtn)
})
expect(defaultProps.onConfirm).toHaveBeenCalled()
})
})
describe('Props', () => {
it('hides close icon when hideCloseBtn is true', () => {
render(<ModalLikeWrap {...defaultProps} hideCloseBtn={true} />)
const closeBtn = document.querySelector('.remixicon')
expect(closeBtn).not.toBeInTheDocument()
})
it('applies custom className', () => {
const { container } = render(<ModalLikeWrap {...defaultProps} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
})
})

View File

@ -1,131 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import ModalLikeWrap from '.'
const meta = {
title: 'Base/Feedback/ModalLikeWrap',
component: ModalLikeWrap,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compact “modal-like” card used in wizards. Provides header actions, optional back slot, and confirm/cancel buttons.',
},
},
},
tags: ['autodocs'],
argTypes: {
title: {
control: 'text',
description: 'Header title text.',
},
className: {
control: 'text',
description: 'Additional classes on the wrapper.',
},
beforeHeader: {
control: false,
description: 'Slot rendered before the header (commonly a back link).',
},
hideCloseBtn: {
control: 'boolean',
description: 'Hides the top-right close icon when true.',
},
children: {
control: false,
},
onClose: {
control: false,
},
onConfirm: {
control: false,
},
},
args: {
title: 'Create dataset field',
hideCloseBtn: false,
onClose: () => console.log('close'),
onConfirm: () => console.log('confirm'),
children: null,
},
} satisfies Meta<typeof ModalLikeWrap>
export default meta
type Story = StoryObj<typeof meta>
const BaseContent = () => (
<div className="space-y-3 text-sm text-gray-600">
<p>
Describe the new field your dataset should collect. Provide a clear label and optional helper text.
</p>
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
Form inputs would be placed here in the real flow.
</div>
</div>
)
export const Default: Story = {
render: args => (
<ModalLikeWrap {...args}>
<BaseContent />
</ModalLikeWrap>
),
args: {
children: null,
},
}
export const WithBackLink: Story = {
render: args => (
<ModalLikeWrap
{...args}
hideCloseBtn
beforeHeader={(
<button
className="mb-1 flex items-center gap-1 text-xs font-medium text-text-accent uppercase"
onClick={() => console.log('back')}
>
<span className="inline-block h-4 w-4 rounded-sm bg-text-accent/10 text-center text-[10px] leading-4 text-text-accent">{'<'}</span>
Back
</button>
)}
>
<BaseContent />
</ModalLikeWrap>
),
args: {
title: 'Select metadata type',
children: null,
},
parameters: {
docs: {
description: {
story: 'Demonstrates feeding content into `beforeHeader` while hiding the close button.',
},
},
},
}
export const CustomWidth: Story = {
render: args => (
<ModalLikeWrap
{...args}
className="w-[420px]"
>
<BaseContent />
<div className="mt-4 rounded-md bg-blue-50 p-3 text-xs text-blue-600">
Tip: metadata keys may only include letters, numbers, and underscores.
</div>
</ModalLikeWrap>
),
args: {
title: 'Advanced configuration',
children: null,
},
parameters: {
docs: {
description: {
story: 'Applies extra width and helper messaging to emulate configuration panels.',
},
},
},
}

View File

@ -1,62 +0,0 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type Props = {
title: string
className?: string
beforeHeader?: React.ReactNode
onClose: () => void
hideCloseBtn?: boolean
onConfirm: () => void
children: React.ReactNode
}
const ModalLikeWrap: FC<Props> = ({
title,
className,
beforeHeader,
children,
onClose,
hideCloseBtn,
onConfirm,
}) => {
const { t } = useTranslation()
return (
<div className={cn('w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-3 pt-3.5 pb-4 shadow-xl', className)}>
{beforeHeader || null}
<div className="mb-1 flex h-6 items-center justify-between">
<div className="system-xl-semibold text-text-primary">{title}</div>
{!hideCloseBtn && (
<div
className="cursor-pointer p-1.5 text-text-tertiary"
onClick={onClose}
>
<span className="i-ri-close-line size-4" data-testid="modal-close-btn" />
</div>
)}
</div>
<div className="mt-2">{children}</div>
<div className="mt-4 flex justify-end">
<Button
className="mr-2"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
onClick={onConfirm}
variant="primary"
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
)
}
export default React.memo(ModalLikeWrap)

View File

@ -1,172 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Modal from '..'
describe('Modal', () => {
describe('Render', () => {
it('should not render content when isShow is false', () => {
render(
<Modal isShow={false} title="Test Modal">
<div>Modal Content</div>
</Modal>,
)
expect(screen.queryByText('Test Modal')).not.toBeInTheDocument()
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument()
})
it('should render content when isShow is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal">
<div>Modal Content</div>
</Modal>,
)
})
expect(screen.getByText('Test Modal')).toBeInTheDocument()
expect(screen.getByText('Modal Content')).toBeInTheDocument()
})
it('should render description when provided', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" description="Test Description">
<div>Content</div>
</Modal>,
)
})
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
})
describe('Interaction', () => {
it('should call onClose when close button is clicked', async () => {
const handleClose = vi.fn()
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" closable={true} onClose={handleClose}>
<div>Content</div>
</Modal>,
)
})
const closeButton = screen.getByTestId('modal-close-button')
expect(closeButton).toBeInTheDocument()
await act(async () => {
fireEvent.click(closeButton!)
})
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should prevent propagation when clicking the scrollable container', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal">
<div>Content</div>
</Modal>,
)
})
const wrapper = document.querySelector('.overflow-y-auto')
expect(wrapper).toBeInTheDocument()
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
await act(async () => {
wrapper!.dispatchEvent(event)
})
expect(stopPropagationSpy).toHaveBeenCalled()
expect(preventDefaultSpy).toHaveBeenCalled()
})
it('should handle clickOutsideNotClose prop', async () => {
const handleClose = vi.fn()
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" clickOutsideNotClose={true} onClose={handleClose}>
<div>Content</div>
</Modal>,
)
})
await act(async () => {
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' })
})
expect(handleClose).not.toHaveBeenCalled()
})
})
describe('Props', () => {
it('should apply custom className to the panel', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" className="custom-panel-class">
<div>Content</div>
</Modal>,
)
})
const panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('custom-panel-class')
})
it('should apply wrapperClassName and containerClassName', async () => {
await act(async () => {
render(
<Modal
isShow={true}
title="Test Modal"
wrapperClassName="custom-wrapper"
containerClassName="custom-container"
>
<div>Content</div>
</Modal>,
)
})
const dialog = document.querySelector('.custom-wrapper')
expect(dialog).toBeInTheDocument()
const container = document.querySelector('.custom-container')
expect(container).toBeInTheDocument()
})
it('should apply overlayOpacity background when overlayOpacity is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" overlayOpacity={true}>
<div>Content</div>
</Modal>,
)
})
const overlay = document.querySelector('.bg-workflow-canvas-canvas-overlay')
expect(overlay).toBeInTheDocument()
})
it('should toggle overflow-visible class based on overflowVisible prop', async () => {
const { rerender } = render(
<Modal isShow={true} title="Test Modal" overflowVisible={true}>
<div>Content</div>
</Modal>,
)
let panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('overflow-visible')
await act(async () => {
rerender(
<Modal isShow={true} title="Test Modal" overflowVisible={false}>
<div>Content</div>
</Modal>,
)
})
panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('overflow-hidden')
})
})
})

View File

@ -1,128 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useEffect, useState } from 'react'
import Modal from '.'
const meta = {
title: 'Base/Feedback/Modal',
component: Modal,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Lightweight modal wrapper with optional header/description and close icon.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Extra classes applied to the modal panel.',
},
wrapperClassName: {
control: 'text',
description: 'Additional wrapper classes for the dialog.',
},
isShow: {
control: 'boolean',
description: 'Controls whether the modal is visible.',
},
title: {
control: 'text',
description: 'Heading displayed at the top of the modal.',
},
description: {
control: 'text',
description: 'Secondary text beneath the title.',
},
closable: {
control: 'boolean',
description: 'Whether the close icon should be shown.',
},
overflowVisible: {
control: 'boolean',
description: 'Allows content to overflow the modal panel.',
},
onClose: {
control: false,
description: 'Callback invoked when the modal requests to close.',
},
},
args: {
isShow: false,
title: 'Create new API key',
description: 'Generate a scoped key for this workspace. You can revoke it at any time.',
closable: true,
},
} satisfies Meta<typeof Modal>
export default meta
type Story = StoryObj<typeof meta>
const ModalDemo = (props: React.ComponentProps<typeof Modal>) => {
const [open, setOpen] = useState(props.isShow)
useEffect(() => {
setOpen(props.isShow)
}, [props.isShow])
return (
<div className="relative flex h-[480px] items-center justify-center bg-gray-100">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Show modal
</button>
<Modal
{...props}
isShow={open}
onClose={() => {
props.onClose?.()
setOpen(false)
}}
>
<div className="mt-6 space-y-4 text-sm text-gray-600">
<p>
Provide a descriptive name for this key so collaborators know its purpose. Restrict usage with scopes to limit access.
</p>
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
Form fields and validation messaging would appear here. This placeholder keeps the story lightweight.
</div>
</div>
<div className="mt-8 flex justify-end gap-3">
<button
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setOpen(false)}
>
Cancel
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Create key
</button>
</div>
</Modal>
</div>
)
}
export const Default: Story = {
render: args => <ModalDemo {...args} />,
}
export const OverflowVisible: Story = {
render: args => <ModalDemo {...args} />,
args: {
overflowVisible: true,
description: 'Demonstrates the modal configured to let the body content overflow.',
className: 'max-w-[540px]',
},
parameters: {
docs: {
description: {
story: 'Shows the modal with `overflowVisible` enabled for content that needs to escape the panel bounds.',
},
},
},
}

View File

@ -1,93 +0,0 @@
/**
* @deprecated Use `@langgenius/dify-ui/dialog` instead.
* This component will be removed after migration is complete.
* See: https://github.com/langgenius/dify/issues/32767
*/
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function'
import { Fragment } from 'react'
// https://headlessui.com/react/dialog
type IModal = {
className?: string
wrapperClassName?: string
containerClassName?: string
isShow: boolean
onClose?: () => void
title?: React.ReactNode
description?: React.ReactNode
children?: React.ReactNode
closable?: boolean
overflowVisible?: boolean
overlayOpacity?: boolean // For semi-transparent overlay instead of default
clickOutsideNotClose?: boolean // Prevent closing when clicking outside modal
}
export default function Modal({
className,
wrapperClassName,
containerClassName,
isShow,
onClose = noop,
title,
description,
children,
closable = false,
overflowVisible = false,
overlayOpacity = false,
clickOutsideNotClose = false,
}: IModal) {
return (
<Transition appear show={isShow} as={Fragment}>
<Dialog as="div" className={cn('relative z-60', wrapperClassName)} onClose={clickOutsideNotClose ? noop : onClose}>
<TransitionChild>
<div className={cn('fixed inset-0', overlayOpacity ? 'bg-workflow-canvas-canvas-overlay' : 'bg-background-overlay', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')} />
</TransitionChild>
<div
className="fixed inset-0 overflow-y-auto"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<div className={cn('flex min-h-full items-center justify-center p-4 text-center', containerClassName)}>
<TransitionChild>
<DialogPanel className={cn('relative w-full max-w-[480px] rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all', overflowVisible ? 'overflow-visible' : 'overflow-hidden', 'duration-100 ease-in data-closed:scale-95 data-closed:opacity-0', 'data-enter:scale-100 data-enter:opacity-100', 'data-enter:scale-95 data-leave:opacity-0', className)}>
{!!title && (
<DialogTitle
as="h3"
className="title-2xl-semi-bold text-text-primary"
>
{title}
</DialogTitle>
)}
{!!description && (
<div className="mt-2 body-md-regular text-text-secondary">
{description}
</div>
)}
{closable
&& (
<div className="absolute top-6 right-6 z-10 flex h-5 w-5 items-center justify-center rounded-2xl hover:cursor-pointer hover:bg-state-base-hover">
<span
className="i-ri-close-line h-4 w-4 text-text-tertiary"
onClick={
(e) => {
e.stopPropagation()
onClose()
}
}
data-testid="modal-close-button"
/>
</div>
)}
{children}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
)
}

View File

@ -4,6 +4,7 @@ import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
@ -11,7 +12,6 @@ import { useTranslation } from 'react-i18next'
import { InputVarType } from '@/app/components/workflow/types'
import ActionButton from '../../../action-button'
import { VariableX } from '../../../icons/src/vender/workflow'
import Modal from '../../../modal'
import InputField from './input-field'
import VariableBlock from './variable-block'
@ -157,20 +157,24 @@ const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
</div>
{isShowEditModal && (
<Modal
isShow
onClose={hideEditModal}
wrapperClassName="z-999"
className="max-w-[372px] p-0!"
<Dialog
open
onOpenChange={(open) => {
if (!open)
hideEditModal()
}}
>
<InputField
nodeId={nodeId}
isEdit
payload={formInput}
onChange={handleChange}
onCancel={hideEditModal}
/>
</Modal>
<DialogContent className="w-full max-w-[372px] overflow-hidden! border-none p-0! text-left align-middle">
<InputField
nodeId={nodeId}
isEdit
payload={formInput}
onChange={handleChange}
onCancel={hideEditModal}
/>
</DialogContent>
</Dialog>
)}
</div>
)

View File

@ -82,7 +82,9 @@ const InputField: React.FC<InputFieldProps> = ({
}, [handleSave])
return (
<div className="w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]">
<div className="w-[372px]
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]"
>
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">

View File

@ -1,4 +1,4 @@
import type { LexicalCommand } from 'lexical'
import type { ShortcutPopupInsertHandler } from '../index'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
@ -50,7 +50,7 @@ const CONTENT_EDITABLE_ID = 'ce'
type MinimalEditorProps = {
withContainer?: boolean
hotkey?: string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: unknown[]) => void) => React.ReactNode)
children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode)
className?: string
onOpen?: () => void
onClose?: () => void
@ -316,7 +316,7 @@ describe('ShortcutsPopupPlugin', () => {
it('renders children as render function and provides close/onInsert', async () => {
const TEST_COMMAND = createCommand<unknown>('TEST_COMMAND')
const childrenFn = vi.fn((close: () => void, onInsert: (cmd: LexicalCommand<unknown>, params: unknown[]) => void) => (
const childrenFn = vi.fn((close: () => void, onInsert: ShortcutPopupInsertHandler) => (
<div>
<button type="button" data-testid="close-btn" onClick={close}>Close</button>
<button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['param1'])}>Insert</button>
@ -346,7 +346,7 @@ describe('ShortcutsPopupPlugin', () => {
const TEST_COMMAND = createCommand<unknown>('TEST_INSERT_COMMAND')
render(
<MinimalEditor>
{(close: () => void, onInsert: (cmd: LexicalCommand<unknown>, params: unknown[]) => void) => (
{(close: () => void, onInsert: ShortcutPopupInsertHandler) => (
<div>
<button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['value'])}>Insert</button>
</div>

View File

@ -23,6 +23,7 @@ import {
import { createPortal } from 'react-dom'
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
export type ShortcutPopupInsertHandler = <Payload>(command: LexicalCommand<Payload>, params: Payload) => void
// Hotkey can be:
// - string: 'mod+/'
@ -33,7 +34,7 @@ export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boo
type ShortcutPopupPluginProps = {
hotkey?: Hotkey
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void) => React.ReactNode)
children?: React.ReactNode | ((close: () => void, onInsert: ShortcutPopupInsertHandler) => React.ReactNode)
className?: string
container?: Element | null
onOpen?: () => void
@ -158,8 +159,9 @@ export default function ShortcutsPopupPlugin({
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${Math.min(400, availableWidth)}px`,
maxHeight: `${Math.min(300, availableHeight)}px`,
overflow: 'auto',
maxHeight: `${Math.max(0, availableHeight)}px`,
overflowX: 'hidden',
overflowY: 'auto',
})
},
padding: 8,
@ -236,7 +238,7 @@ export default function ShortcutsPopupPlugin({
setOpen(true)
onOpen?.()
}, [onOpen])
}, [editor, onOpen, refs])
const closePortal = useCallback(() => {
setOpen(false)
@ -280,7 +282,7 @@ export default function ShortcutsPopupPlugin({
return () => document.removeEventListener('mousedown', onMouseDown, false)
}, [open, closePortal])
const handleInsert = useCallback((command: LexicalCommand<unknown>, params: any) => {
const handleInsert = useCallback(<Payload,>(command: LexicalCommand<Payload>, params: Payload) => {
editor.dispatchCommand(command, params)
closePortal()
}, [editor, closePortal])

View File

@ -29,26 +29,26 @@ type ModalSnapshot = {
className?: string
}
let mockModalProps: ModalSnapshot | null = null
vi.mock('../../../base/modal', () => ({
default: ({ isShow, children, onClose, closable, className }: { isShow: boolean, children: React.ReactNode, onClose: () => void, closable?: boolean, className?: string }) => {
let mockOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@langgenius/dify-ui/dialog', () => ({
Dialog: ({ open, onOpenChange, children }: { open?: boolean, onOpenChange?: (open: boolean) => void, children: React.ReactNode }) => {
mockOnOpenChange = onOpenChange
mockModalProps = { isShow: open !== false }
return open === false ? null : <>{children}</>
},
DialogContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
mockModalProps = {
isShow,
closable,
isShow: true,
closable: true,
className,
}
if (!isShow)
return null
return (
<div data-testid="annotation-full-modal" data-classname={className ?? ''}>
{closable && (
<button type="button" data-testid="mock-modal-close" onClick={onClose}>
close
</button>
)}
{children}
</div>
)
},
DialogCloseButton: () => <button type="button" data-testid="mock-modal-close" onClick={() => mockOnOpenChange?.(false)}>close</button>,
}))
describe('AnnotationFullModal', () => {
@ -56,6 +56,7 @@ describe('AnnotationFullModal', () => {
vi.clearAllMocks()
mockUpgradeBtnProps = null
mockModalProps = null
mockOnOpenChange = undefined
})
// Rendering marketing copy inside modal
@ -71,8 +72,9 @@ describe('AnnotationFullModal', () => {
expect(mockModalProps).toEqual(expect.objectContaining({
isShow: true,
closable: true,
className: 'p-0!',
className: expect.stringContaining('p-0!'),
}))
expect(mockModalProps?.className).toContain('w-full')
})
})

View File

@ -1,10 +1,10 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import GridMask from '@/app/components/base/grid-mask'
import Modal from '../../base/modal'
import UpgradeBtn from '../upgrade-btn'
import s from './style.module.css'
import Usage from './usage'
@ -20,28 +20,33 @@ const AnnotationFullModal: FC<Props> = ({
const { t } = useTranslation()
return (
<Modal
isShow={show}
onClose={onHide}
closable
className="p-0!"
<Dialog
open={show}
onOpenChange={(open) => {
if (!open)
onHide()
}}
>
<GridMask wrapperClassName="rounded-lg" canvasClassName="rounded-lg" gradientClassName="rounded-lg">
<div className="mt-6 flex cursor-pointer flex-col rounded-lg border-2 border-solid border-transparent px-7 py-6 shadow-md transition-all duration-200 ease-in-out">
<div className="flex items-center justify-between">
<div className={cn(s.textGradient, 'text-[18px] leading-[27px] font-semibold')}>
<div>{t('annotatedResponse.fullTipLine1', { ns: 'billing' })}</div>
<div>{t('annotatedResponse.fullTipLine2', { ns: 'billing' })}</div>
</div>
<DialogContent className="w-full overflow-hidden! border-none p-0! text-left align-middle">
<DialogCloseButton data-testid="modal-close-button" />
<GridMask wrapperClassName="rounded-lg" canvasClassName="rounded-lg" gradientClassName="rounded-lg">
<div className="mt-6 flex cursor-pointer flex-col rounded-lg border-2 border-solid border-transparent px-7 py-6 shadow-md transition-all duration-200 ease-in-out">
<div className="flex items-center justify-between">
<div className={cn(s.textGradient, 'text-[18px] leading-[27px] font-semibold')}>
<div>{t('annotatedResponse.fullTipLine1', { ns: 'billing' })}</div>
<div>{t('annotatedResponse.fullTipLine2', { ns: 'billing' })}</div>
</div>
</div>
<Usage className="mt-4" />
<div className="mt-7 flex justify-end">
<UpgradeBtn loc="annotation-create" />
</div>
</div>
<Usage className="mt-4" />
<div className="mt-7 flex justify-end">
<UpgradeBtn loc="annotation-create" />
</div>
</div>
</GridMask>
</Modal>
</GridMask>
</DialogContent>
</Dialog>
)
}
export default React.memo(AnnotationFullModal)

View File

@ -42,7 +42,7 @@ type ImageInfo = {
size: number
}
// Mock ImagePreviewer since it uses createPortal
// Mock ImagePreviewer since it renders through a Dialog portal
vi.mock('../../image-previewer', () => ({
default: ({ images, initialIndex, onClose }: ImagePreviewerProps) => (
<div data-testid="image-previewer">

View File

@ -474,23 +474,18 @@ describe('ImagePreviewer', () => {
expect(nextButton)!.toBeDisabled()
})
it('should stop event propagation on container click', async () => {
it('should not close on container click', async () => {
const onClose = vi.fn()
const parentClick = vi.fn()
const images = createMockImages()
await act(async () => {
render(
<div onClick={parentClick}>
<ImagePreviewer images={images} onClose={onClose} />
</div>,
)
render(<ImagePreviewer images={images} onClose={onClose} />)
})
const container = document.querySelector('.image-previewer')
if (container) {
fireEvent.click(container)
expect(parentClick).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
}
})

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