mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
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:
parent
f720a3bed2
commit
8581a68174
@ -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
|
||||
|
||||
@ -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
131
pnpm-lock.yaml
generated
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}</>)
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ describe('AccessControlDialog', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toHaveClass('custom-dialog')
|
||||
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
51
web/app/components/app/create-app-dialog-shell.tsx
Normal file
51
web/app/components/app/create-app-dialog-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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}
|
||||
{' '}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
: <></>
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
{t('settings.provider', { ns: 'common' })}
|
||||
|
||||
</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}
|
||||
>
|
||||
|
||||
{t('settings.provider', { ns: 'common' })}
|
||||
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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!)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)' })
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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)
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user