mirror of
https://github.com/langgenius/dify.git
synced 2026-06-19 00:21:10 +08:00
refactor(web): continue replacing PortalToFollowElem with Popover components (#35431)
Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
da00de6688
commit
d583b1b835
@ -129,11 +129,6 @@
|
|||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": {
|
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -1086,21 +1081,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/base/date-and-time-picker/date-picker/index.tsx": {
|
|
||||||
"react/set-state-in-effect": {
|
|
||||||
"count": 4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/base/date-and-time-picker/hooks.ts": {
|
"web/app/components/base/date-and-time-picker/hooks.ts": {
|
||||||
"react/no-unnecessary-use-prefix": {
|
"react/no-unnecessary-use-prefix": {
|
||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/base/date-and-time-picker/time-picker/index.tsx": {
|
|
||||||
"react/set-state-in-effect": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/base/date-and-time-picker/types.ts": {
|
"web/app/components/base/date-and-time-picker/types.ts": {
|
||||||
"erasable-syntax-only/enums": {
|
"erasable-syntax-only/enums": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@ -1195,11 +1180,6 @@
|
|||||||
"count": 5
|
"count": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": {
|
"web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -1223,11 +1203,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/base/features/types.ts": {
|
"web/app/components/base/features/types.ts": {
|
||||||
"erasable-syntax-only/enums": {
|
"erasable-syntax-only/enums": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@ -1878,11 +1853,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/base/portal-to-follow-elem/index.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/base/prompt-editor/index.stories.tsx": {
|
"web/app/components/base/prompt-editor/index.stories.tsx": {
|
||||||
"no-console": {
|
"no-console": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -1906,11 +1876,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/base/prompt-editor/plugins/context-block/component.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/base/prompt-editor/plugins/context-block/index.tsx": {
|
"web/app/components/base/prompt-editor/plugins/context-block/index.tsx": {
|
||||||
"no-barrel-files/no-barrel-files": {
|
"no-barrel-files/no-barrel-files": {
|
||||||
"count": 3
|
"count": 3
|
||||||
@ -1940,11 +1905,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/base/prompt-editor/plugins/history-block/component.tsx": {
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/base/prompt-editor/plugins/history-block/index.tsx": {
|
"web/app/components/base/prompt-editor/plugins/history-block/index.tsx": {
|
||||||
"no-barrel-files/no-barrel-files": {
|
"no-barrel-files/no-barrel-files": {
|
||||||
"count": 3
|
"count": 3
|
||||||
@ -2268,16 +2228,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/datasets/common/document-picker/index.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/datasets/common/document-picker/preview-document-picker.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/datasets/common/image-previewer/index.tsx": {
|
"web/app/components/datasets/common/image-previewer/index.tsx": {
|
||||||
"no-irregular-whitespace": {
|
"no-irregular-whitespace": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -2894,14 +2844,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/datasets/settings/permission-selector/index.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"react/no-missing-key": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/datasets/settings/summary-index-setting.tsx": {
|
"web/app/components/datasets/settings/summary-index-setting.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -3069,21 +3011,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/header/account-setting/api-based-extension-page/selector.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/header/account-setting/data-source-page-new/card.tsx": {
|
"web/app/components/header/account-setting/data-source-page-new/card.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/header/account-setting/data-source-page-new/configure.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": {
|
"web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": {
|
||||||
"no-barrel-files/no-barrel-files": {
|
"no-barrel-files/no-barrel-files": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@ -3167,19 +3099,6 @@
|
|||||||
"count": 4
|
"count": 4
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": {
|
"web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -3411,11 +3330,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/plugins/marketplace/search-box/tags-filter.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
|
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@ -3447,14 +3361,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/plugins/plugin-auth/authorized/item.tsx": {
|
"web/app/components/plugins/plugin-auth/authorized/item.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -3510,16 +3416,6 @@
|
|||||||
"count": 8
|
"count": 8
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
|
"web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -3713,11 +3609,6 @@
|
|||||||
"count": 2
|
"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": {
|
"web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 5
|
"count": 5
|
||||||
@ -3756,16 +3647,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/plugins/plugin-page/index.tsx": {
|
"web/app/components/plugins/plugin-page/index.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -3918,11 +3799,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx": {
|
"web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -4248,11 +4124,6 @@
|
|||||||
"count": 1
|
"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": {
|
"web/app/components/workflow/block-selector/market-place-plugin/action.tsx": {
|
||||||
"react/set-state-in-effect": {
|
"react/set-state-in-effect": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -4378,19 +4249,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/workflow/header/view-history.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/header/view-workflow-history.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"ts/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/hooks-store/index.ts": {
|
"web/app/components/workflow/hooks-store/index.ts": {
|
||||||
"no-barrel-files/no-barrel-files": {
|
"no-barrel-files/no-barrel-files": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@ -5053,11 +4911,6 @@
|
|||||||
"count": 5
|
"count": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": {
|
"web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -5306,16 +5159,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/nodes/knowledge-retrieval/default.ts": {
|
"web/app/components/workflow/nodes/knowledge-retrieval/default.ts": {
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -5414,17 +5257,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": {
|
|
||||||
"erasable-syntax-only/enums": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"react/set-state-in-effect": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx": {
|
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -5871,24 +5703,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"react-refresh/only-export-components": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/note-node/note-editor/toolbar/command.tsx": {
|
"web/app/components/workflow/note-node/note-editor/toolbar/command.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/note-node/note-editor/utils.ts": {
|
"web/app/components/workflow/note-node/note-editor/utils.ts": {
|
||||||
"regexp/no-useless-quantifier": {
|
"regexp/no-useless-quantifier": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@ -6012,11 +5831,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web/app/components/workflow/panel/version-history-panel/filter/index.tsx": {
|
|
||||||
"no-restricted-imports": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": {
|
"web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": {
|
||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
127
web/__mocks__/__tests__/base-ui-popover.spec.tsx
Normal file
127
web/__mocks__/__tests__/base-ui-popover.spec.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '../base-ui-popover'
|
||||||
|
|
||||||
|
type PopoverHarnessProps = {
|
||||||
|
useRenderElement?: boolean
|
||||||
|
preventDefaultOnTrigger?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopoverHarness = ({
|
||||||
|
useRenderElement = false,
|
||||||
|
preventDefaultOnTrigger = false,
|
||||||
|
}: PopoverHarnessProps) => {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="outside-area">outside</div>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={useRenderElement
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="custom-trigger"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (preventDefaultOnTrigger)
|
||||||
|
event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
toggle
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
fallback trigger
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="custom-content"
|
||||||
|
placement="bottom-start"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={8}
|
||||||
|
positionerProps={{ 'data-positioner': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
|
||||||
|
popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
|
||||||
|
>
|
||||||
|
<div>popover body</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<div data-testid="open-state">{open ? 'open' : 'closed'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('base-ui-popover mock', () => {
|
||||||
|
it('should toggle popover content from the fallback trigger and expose content props', () => {
|
||||||
|
render(<PopoverHarness />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||||
|
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
|
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||||
|
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-placement', 'bottom-start')
|
||||||
|
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-side-offset', '4')
|
||||||
|
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-align-offset', '8')
|
||||||
|
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-positioner', 'true')
|
||||||
|
expect(screen.getByTestId('popover-content')).toHaveAttribute('data-popup', 'true')
|
||||||
|
expect(screen.getByTestId('popover-content')).toHaveClass('custom-content')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the popover open on inside clicks and close it on outside clicks or escape', () => {
|
||||||
|
render(<PopoverHarness useRenderElement />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||||
|
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByTestId('popover-content'))
|
||||||
|
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' })
|
||||||
|
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||||
|
expect(screen.getByTestId('open-state')).toHaveTextContent('open')
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByTestId('outside-area'))
|
||||||
|
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve rendered trigger props and respect preventDefault', () => {
|
||||||
|
render(<PopoverHarness useRenderElement preventDefaultOnTrigger />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||||
|
|
||||||
|
expect(screen.getByTestId('custom-trigger')).toHaveAttribute('data-popover-trigger', 'true')
|
||||||
|
expect(screen.getByTestId('open-state')).toHaveTextContent('closed')
|
||||||
|
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the popover closed when the fallback trigger click is prevented', () => {
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<div>
|
||||||
|
<Popover open={false} onOpenChange={vi.fn()}>
|
||||||
|
<PopoverTrigger onClick={handleClick}>
|
||||||
|
fallback trigger
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
<div>popover body</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
154
web/__mocks__/base-ui-popover.tsx
Normal file
154
web/__mocks__/base-ui-popover.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
const PopoverContext = React.createContext({
|
||||||
|
open: false,
|
||||||
|
onOpenChange: (_open: boolean) => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
type PopoverProps = {
|
||||||
|
children?: ReactNode
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & {
|
||||||
|
children?: ReactNode
|
||||||
|
nativeButton?: boolean
|
||||||
|
render?: React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
children?: ReactNode
|
||||||
|
placement?: string
|
||||||
|
sideOffset?: number
|
||||||
|
alignOffset?: number
|
||||||
|
positionerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||||
|
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Popover = ({
|
||||||
|
children,
|
||||||
|
open = false,
|
||||||
|
onOpenChange,
|
||||||
|
}: PopoverProps) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open)
|
||||||
|
return
|
||||||
|
|
||||||
|
const handleMouseDown = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Element | null
|
||||||
|
if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]'))
|
||||||
|
return
|
||||||
|
|
||||||
|
onOpenChange?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape')
|
||||||
|
onOpenChange?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleMouseDown)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleMouseDown)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [open, onOpenChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverContext.Provider value={{
|
||||||
|
open,
|
||||||
|
onOpenChange: onOpenChange ?? (() => {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-testid="popover" data-open={String(open)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</PopoverContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PopoverTrigger = ({
|
||||||
|
children,
|
||||||
|
render,
|
||||||
|
nativeButton: _nativeButton,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: PopoverTriggerProps) => {
|
||||||
|
const { open, onOpenChange } = React.useContext(PopoverContext)
|
||||||
|
const node = render ?? children
|
||||||
|
|
||||||
|
if (React.isValidElement(node)) {
|
||||||
|
const triggerElement = node as React.ReactElement<Record<string, unknown>>
|
||||||
|
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
|
||||||
|
|
||||||
|
return React.cloneElement(triggerElement, {
|
||||||
|
...props,
|
||||||
|
...childProps,
|
||||||
|
'data-testid': childProps['data-testid'] ?? 'popover-trigger',
|
||||||
|
'data-popover-trigger': 'true',
|
||||||
|
'onClick': (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
childProps.onClick?.(event)
|
||||||
|
onClick?.(event)
|
||||||
|
if (event.defaultPrevented)
|
||||||
|
return
|
||||||
|
onOpenChange(!open)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="popover-trigger"
|
||||||
|
data-popover-trigger="true"
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
if (event.defaultPrevented)
|
||||||
|
return
|
||||||
|
onOpenChange(!open)
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{node}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PopoverContent = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
placement,
|
||||||
|
sideOffset,
|
||||||
|
alignOffset,
|
||||||
|
positionerProps,
|
||||||
|
popupProps,
|
||||||
|
...props
|
||||||
|
}: PopoverContentProps) => {
|
||||||
|
const { open } = React.useContext(PopoverContext)
|
||||||
|
|
||||||
|
if (!open)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="popover-content"
|
||||||
|
data-popover-content="true"
|
||||||
|
data-placement={placement}
|
||||||
|
data-side-offset={sideOffset}
|
||||||
|
data-align-offset={alignOffset}
|
||||||
|
className={className}
|
||||||
|
{...positionerProps}
|
||||||
|
{...popupProps}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||||
|
export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||||
|
export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||||
@ -3,13 +3,13 @@ import type { FC } from 'react'
|
|||||||
import type { PopupProps } from './config-popup'
|
import type { PopupProps } from './config-popup'
|
||||||
|
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
import * as React from 'react'
|
|
||||||
import { useCallback, useRef, useState } from 'react'
|
|
||||||
import {
|
import {
|
||||||
PortalToFollowElem,
|
Popover,
|
||||||
PortalToFollowElemContent,
|
PopoverContent,
|
||||||
PortalToFollowElemTrigger,
|
PopoverTrigger,
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
} from '@langgenius/dify-ui/popover'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
import ConfigPopup from './config-popup'
|
import ConfigPopup from './config-popup'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -25,36 +25,31 @@ const ConfigBtn: FC<Props> = ({
|
|||||||
children,
|
children,
|
||||||
...popupProps
|
...popupProps
|
||||||
}) => {
|
}) => {
|
||||||
const [open, doSetOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const openRef = useRef(open)
|
|
||||||
const setOpen = useCallback((v: boolean) => {
|
|
||||||
doSetOpen(v)
|
|
||||||
openRef.current = v
|
|
||||||
}, [doSetOpen])
|
|
||||||
|
|
||||||
const handleTrigger = useCallback(() => {
|
|
||||||
setOpen(!openRef.current)
|
|
||||||
}, [setOpen])
|
|
||||||
|
|
||||||
if (popupProps.readOnly && !hasConfigured)
|
if (popupProps.readOnly && !hasConfigured)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-end"
|
|
||||||
offset={12}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
<PopoverTrigger
|
||||||
<div className={cn('select-none', className)}>
|
render={(
|
||||||
{children}
|
<div className={cn('select-none', className)}>
|
||||||
</div>
|
{children}
|
||||||
</PortalToFollowElemTrigger>
|
</div>
|
||||||
<PortalToFollowElemContent className="z-11">
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={12}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<ConfigPopup {...popupProps} />
|
<ConfigPopup {...popupProps} />
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default React.memo(ConfigBtn)
|
export default React.memo(ConfigBtn)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiChatSettingsLine,
|
RiChatSettingsLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
@ -6,30 +7,29 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
|
|
||||||
const ViewFormDropdown = () => {
|
const ViewFormDropdown = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: 4,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
onClick={() => setOpen(v => !v)}
|
render={(
|
||||||
|
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||||
|
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
>
|
>
|
||||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
|
||||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
|
||||||
</ActionButton>
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent className="z-50">
|
|
||||||
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs">
|
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs">
|
||||||
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
|
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
|
||||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||||
@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
|
|||||||
<InputsFormContent />
|
<InputsFormContent />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { useDocumentDownload } from '@/service/knowledge/use-document'
|
|||||||
import { downloadUrl } from '@/utils/download'
|
import { downloadUrl } from '@/utils/download'
|
||||||
import Popup from '../popup'
|
import Popup from '../popup'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
|
||||||
vi.mock('@/service/knowledge/use-document', () => ({
|
vi.mock('@/service/knowledge/use-document', () => ({
|
||||||
useDocumentDownload: vi.fn(),
|
useDocumentDownload: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import type { FC, MouseEvent } from 'react'
|
import type { FC, MouseEvent } from 'react'
|
||||||
import type { Resources } from './index'
|
import type { Resources } from './index'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import { Fragment, useState } from 'react'
|
import { Fragment, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import FileIcon from '@/app/components/base/file-icon'
|
import FileIcon from '@/app/components/base/file-icon'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import Link from '@/next/link'
|
import Link from '@/next/link'
|
||||||
import { useDocumentDownload } from '@/service/knowledge/use-document'
|
import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||||
import { downloadUrl } from '@/utils/download'
|
import { downloadUrl } from '@/utils/download'
|
||||||
@ -47,22 +43,25 @@ const Popup: FC<PopupProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="top-start"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 8,
|
|
||||||
crossAxis: -2,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
<PopoverTrigger
|
||||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
nativeButton={false}
|
||||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
render={(
|
||||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||||
</div>
|
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||||
</PortalToFollowElemTrigger>
|
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="top-start"
|
||||||
|
sideOffset={8}
|
||||||
|
alignOffset={-2}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
||||||
<div className="px-4 pt-3 pb-2">
|
<div className="px-4 pt-3 pb-2">
|
||||||
<div className="flex h-[18px] items-center">
|
<div className="flex h-[18px] items-center">
|
||||||
@ -156,8 +155,8 @@ const Popup: FC<PopupProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
@ -17,25 +17,27 @@ const ViewFormDropdown = ({
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: 4,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
<PopoverTrigger
|
||||||
<ActionButton
|
render={(
|
||||||
size="l"
|
<ActionButton
|
||||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
size="l"
|
||||||
data-testid="view-form-dropdown-trigger"
|
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||||
>
|
data-testid="view-form-dropdown-trigger"
|
||||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
>
|
||||||
</ActionButton>
|
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||||
</PortalToFollowElemTrigger>
|
</ActionButton>
|
||||||
<PortalToFollowElemContent className="z-99">
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="view-form-dropdown-content"
|
data-testid="view-form-dropdown-content"
|
||||||
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs"
|
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs"
|
||||||
@ -48,8 +50,8 @@ const ViewFormDropdown = ({
|
|||||||
<InputsFormContent />
|
<InputsFormContent />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,20 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
|||||||
import dayjs from '../../utils/dayjs'
|
import dayjs from '../../utils/dayjs'
|
||||||
import DatePicker from '../index'
|
import DatePicker from '../index'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||||
|
Button: ({ children, onClick, disabled, className }: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}) => (
|
||||||
|
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock scrollIntoView
|
// Mock scrollIntoView
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Element.prototype.scrollIntoView = vi.fn()
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
@ -113,14 +127,13 @@ describe('DatePicker', () => {
|
|||||||
render(<DatePicker {...props} />)
|
render(<DatePicker {...props} />)
|
||||||
|
|
||||||
openPicker()
|
openPicker()
|
||||||
|
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
|
||||||
|
|
||||||
// Simulate a mousedown event outside the container
|
|
||||||
act(() => {
|
act(() => {
|
||||||
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||||
})
|
})
|
||||||
|
|
||||||
// The picker should now be closed - input shows its value
|
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
|
||||||
// The picker should now be closed - input shows its value
|
|
||||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
import type { DatePickerProps, Period } from '../types'
|
import type { DatePickerProps, Period } from '../types'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import Calendar from '../calendar'
|
import Calendar from '../calendar'
|
||||||
import TimePickerHeader from '../time-picker/header'
|
import TimePickerHeader from '../time-picker/header'
|
||||||
import TimePickerOptions from '../time-picker/options'
|
import TimePickerOptions from '../time-picker/options'
|
||||||
@ -35,15 +31,14 @@ const DatePicker = ({
|
|||||||
needTimePicker = true,
|
needTimePicker = true,
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
triggerWrapClassName,
|
triggerWrapClassName,
|
||||||
popupZIndexClassname = 'z-11',
|
popupZIndexClassname,
|
||||||
noConfirm,
|
noConfirm,
|
||||||
getIsDateDisabled,
|
getIsDateDisabled,
|
||||||
}: DatePickerProps) => {
|
}: DatePickerProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [view, setView] = useState(ViewType.date)
|
const [view, setView] = useState(ViewType.date)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const isInitialRef = useRef(true)
|
||||||
const isInitial = useRef(true)
|
|
||||||
|
|
||||||
// Normalize the value to ensure that all subsequent uses are Day.js objects.
|
// Normalize the value to ensure that all subsequent uses are Day.js objects.
|
||||||
const normalizedValue = useMemo(() => {
|
const normalizedValue = useMemo(() => {
|
||||||
@ -62,46 +57,41 @@ const DatePicker = ({
|
|||||||
const [selectedYear, setSelectedYear] = useState(() => (inputValue || defaultValue).year())
|
const [selectedYear, setSelectedYear] = useState(() => (inputValue || defaultValue).year())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
if (isInitialRef.current) {
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
isInitialRef.current = false
|
||||||
setIsOpen(false)
|
|
||||||
setView(ViewType.date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitial.current) {
|
|
||||||
isInitial.current = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearMonthMapCache()
|
clearMonthMapCache()
|
||||||
if (normalizedValue) {
|
if (normalizedValue) {
|
||||||
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
|
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
|
||||||
|
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
|
||||||
setCurrentDate(newValue)
|
setCurrentDate(newValue)
|
||||||
|
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
|
||||||
setSelectedDate(newValue)
|
setSelectedDate(newValue)
|
||||||
onChange(newValue)
|
onChange(newValue)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state.
|
||||||
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
|
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
|
||||||
|
// eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value.
|
||||||
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
|
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes.
|
||||||
}, [timezone])
|
}, [timezone])
|
||||||
|
|
||||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||||
e.stopPropagation()
|
setIsOpen(nextOpen)
|
||||||
if (isOpen) {
|
|
||||||
setIsOpen(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setView(ViewType.date)
|
setView(ViewType.date)
|
||||||
setIsOpen(true)
|
if (nextOpen && normalizedValue) {
|
||||||
if (normalizedValue) {
|
|
||||||
setCurrentDate(normalizedValue)
|
setCurrentDate(normalizedValue)
|
||||||
setSelectedDate(normalizedValue)
|
setSelectedDate(normalizedValue)
|
||||||
}
|
}
|
||||||
|
}, [normalizedValue])
|
||||||
|
|
||||||
|
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleOpenChange(!isOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = (e: React.MouseEvent) => {
|
const handleClear = (e: React.MouseEvent) => {
|
||||||
@ -210,21 +200,21 @@ const DatePicker = ({
|
|||||||
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('defaultPlaceholder', { ns: 'time' }))
|
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('defaultPlaceholder', { ns: 'time' }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={handleOpenChange}
|
||||||
placement="bottom-end"
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger className={triggerWrapClassName}>
|
<PopoverTrigger
|
||||||
{renderTrigger
|
nativeButton={false}
|
||||||
? (
|
className={triggerWrapClassName}
|
||||||
renderTrigger({
|
render={renderTrigger
|
||||||
value: normalizedValue,
|
? renderTrigger({
|
||||||
selectedDate,
|
value: normalizedValue,
|
||||||
isOpen,
|
selectedDate,
|
||||||
handleClear,
|
isOpen,
|
||||||
handleClickTrigger,
|
handleClear,
|
||||||
}))
|
handleClickTrigger,
|
||||||
|
})
|
||||||
: (
|
: (
|
||||||
<div
|
<div
|
||||||
className="group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt"
|
className="group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt"
|
||||||
@ -242,8 +232,13 @@ const DatePicker = ({
|
|||||||
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary')} onClick={handleClear} data-testid="date-picker-clear-button" />
|
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary')} onClick={handleClear} data-testid="date-picker-clear-button" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PortalToFollowElemTrigger>
|
/>
|
||||||
<PortalToFollowElemContent className={popupZIndexClassname}>
|
<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">
|
<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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{view === ViewType.date
|
{view === ViewType.date
|
||||||
@ -319,8 +314,8 @@ const DatePicker = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,20 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
|
|||||||
import dayjs, { isDayjsObject } from '../../utils/dayjs'
|
import dayjs, { isDayjsObject } from '../../utils/dayjs'
|
||||||
import TimePicker from '../index'
|
import TimePicker from '../index'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||||
|
Button: ({ children, onClick, disabled, className }: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}) => (
|
||||||
|
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock scrollIntoView since the test DOM runtime doesn't implement it
|
// Mock scrollIntoView since the test DOM runtime doesn't implement it
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Element.prototype.scrollIntoView = vi.fn()
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
@ -106,7 +120,7 @@ describe('TimePicker', () => {
|
|||||||
expect(input)!.toHaveValue('')
|
expect(input)!.toHaveValue('')
|
||||||
|
|
||||||
fireEvent.mouseDown(document.body)
|
fireEvent.mouseDown(document.body)
|
||||||
expect(input)!.toHaveValue('')
|
expect(input)!.toHaveValue('10:00 AM')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call onClear when clear is clicked while picker is closed', () => {
|
it('should call onClear when clear is clicked while picker is closed', () => {
|
||||||
|
|||||||
@ -1,14 +1,10 @@
|
|||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
import type { TimePickerProps } from '../types'
|
import type { TimePickerProps } from '../types'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import TimezoneLabel from '@/app/components/base/timezone-label'
|
import TimezoneLabel from '@/app/components/base/timezone-label'
|
||||||
import { Period } from '../types'
|
import { Period } from '../types'
|
||||||
import dayjs, {
|
import dayjs, {
|
||||||
@ -43,31 +39,20 @@ const TimePicker = ({
|
|||||||
}: TimePickerProps) => {
|
}: TimePickerProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const isInitialRef = useRef(true)
|
||||||
const isInitial = useRef(true)
|
|
||||||
|
|
||||||
// Initialize selectedTime
|
// Initialize selectedTime
|
||||||
const [selectedTime, setSelectedTime] = useState(() => {
|
const [selectedTime, setSelectedTime] = useState(() => {
|
||||||
return toDayjs(value, { timezone })
|
return toDayjs(value, { timezone })
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
/* v8 ignore next 2 -- outside-click closing is handled by PortalToFollowElem; this local ref guard is a defensive fallback. */
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node))
|
|
||||||
setIsOpen(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Track previous values to avoid unnecessary updates
|
// Track previous values to avoid unnecessary updates
|
||||||
const prevValueRef = useRef(value)
|
const prevValueRef = useRef(value)
|
||||||
const prevTimezoneRef = useRef(timezone)
|
const prevTimezoneRef = useRef(timezone)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitial.current) {
|
if (isInitialRef.current) {
|
||||||
isInitial.current = false
|
isInitialRef.current = false
|
||||||
// Save initial values on first render
|
// Save initial values on first render
|
||||||
prevValueRef.current = value
|
prevValueRef.current = value
|
||||||
prevTimezoneRef.current = timezone
|
prevTimezoneRef.current = timezone
|
||||||
@ -91,6 +76,7 @@ const TimePicker = ({
|
|||||||
if (!dayjsValue)
|
if (!dayjsValue)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
|
||||||
setSelectedTime(dayjsValue)
|
setSelectedTime(dayjsValue)
|
||||||
|
|
||||||
if (timezoneChanged && !valueChanged)
|
if (timezoneChanged && !valueChanged)
|
||||||
@ -98,6 +84,7 @@ const TimePicker = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time.
|
||||||
setSelectedTime((prev) => {
|
setSelectedTime((prev) => {
|
||||||
if (!isDayjsObject(prev))
|
if (!isDayjsObject(prev))
|
||||||
return undefined
|
return undefined
|
||||||
@ -105,24 +92,30 @@ const TimePicker = ({
|
|||||||
})
|
})
|
||||||
}, [timezone, value, onChange])
|
}, [timezone, value, onChange])
|
||||||
|
|
||||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
const syncSelectedTimeFromValue = useCallback(() => {
|
||||||
e.stopPropagation()
|
if (!value)
|
||||||
if (isOpen) {
|
|
||||||
setIsOpen(false)
|
|
||||||
return
|
return
|
||||||
}
|
|
||||||
setIsOpen(true)
|
|
||||||
|
|
||||||
if (value) {
|
const dayjsValue = toDayjs(value, { timezone })
|
||||||
const dayjsValue = toDayjs(value, { timezone })
|
const needsUpdate = dayjsValue && (
|
||||||
const needsUpdate = dayjsValue && (
|
!selectedTime
|
||||||
!selectedTime
|
|| !isDayjsObject(selectedTime)
|
||||||
|| !isDayjsObject(selectedTime)
|
|| !dayjsValue.isSame(selectedTime, 'minute')
|
||||||
|| !dayjsValue.isSame(selectedTime, 'minute')
|
)
|
||||||
)
|
if (needsUpdate)
|
||||||
if (needsUpdate)
|
setSelectedTime(dayjsValue)
|
||||||
setSelectedTime(dayjsValue)
|
}, [selectedTime, timezone, value])
|
||||||
}
|
|
||||||
|
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||||
|
setIsOpen(nextOpen)
|
||||||
|
if (nextOpen)
|
||||||
|
syncSelectedTimeFromValue()
|
||||||
|
}, [syncSelectedTimeFromValue])
|
||||||
|
|
||||||
|
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleOpenChange(!isOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = (e: React.MouseEvent) => {
|
const handleClear = (e: React.MouseEvent) => {
|
||||||
@ -132,7 +125,7 @@ const TimePicker = ({
|
|||||||
onClear()
|
onClear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTimeSelect = (hour: string, minute: string, period: Period) => {
|
const handleTimeSelect = useCallback((hour: string, minute: string, period: Period) => {
|
||||||
const periodAdjustedHour = to24Hour(hour, period)
|
const periodAdjustedHour = to24Hour(hour, period)
|
||||||
const nextMinute = Number.parseInt(minute, 10)
|
const nextMinute = Number.parseInt(minute, 10)
|
||||||
setSelectedTime((prev) => {
|
setSelectedTime((prev) => {
|
||||||
@ -145,7 +138,7 @@ const TimePicker = ({
|
|||||||
.set('second', 0)
|
.set('second', 0)
|
||||||
.set('millisecond', 0)
|
.set('millisecond', 0)
|
||||||
})
|
})
|
||||||
}
|
}, [timezone])
|
||||||
|
|
||||||
const getSafeTimeObject = useCallback(() => {
|
const getSafeTimeObject = useCallback(() => {
|
||||||
if (isDayjsObject(selectedTime))
|
if (isDayjsObject(selectedTime))
|
||||||
@ -156,17 +149,17 @@ const TimePicker = ({
|
|||||||
const handleSelectHour = useCallback((hour: string) => {
|
const handleSelectHour = useCallback((hour: string) => {
|
||||||
const time = getSafeTimeObject()
|
const time = getSafeTimeObject()
|
||||||
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
|
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
|
||||||
}, [getSafeTimeObject])
|
}, [getSafeTimeObject, handleTimeSelect])
|
||||||
|
|
||||||
const handleSelectMinute = useCallback((minute: string) => {
|
const handleSelectMinute = useCallback((minute: string) => {
|
||||||
const time = getSafeTimeObject()
|
const time = getSafeTimeObject()
|
||||||
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
|
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
|
||||||
}, [getSafeTimeObject])
|
}, [getSafeTimeObject, handleTimeSelect])
|
||||||
|
|
||||||
const handleSelectPeriod = useCallback((period: Period) => {
|
const handleSelectPeriod = useCallback((period: Period) => {
|
||||||
const time = getSafeTimeObject()
|
const time = getSafeTimeObject()
|
||||||
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
|
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
|
||||||
}, [getSafeTimeObject])
|
}, [getSafeTimeObject, handleTimeSelect])
|
||||||
|
|
||||||
const handleSelectCurrentTime = useCallback(() => {
|
const handleSelectCurrentTime = useCallback(() => {
|
||||||
const newDate = getDateWithTimezone({ timezone })
|
const newDate = getDateWithTimezone({ timezone })
|
||||||
@ -207,18 +200,19 @@ const TimePicker = ({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={handleOpenChange}
|
||||||
placement={placement}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger className={triggerFullWidth ? 'block! w-full' : undefined}>
|
<PopoverTrigger
|
||||||
{renderTrigger
|
nativeButton={false}
|
||||||
? (renderTrigger({
|
className={triggerFullWidth ? 'block! w-full' : undefined}
|
||||||
|
render={renderTrigger
|
||||||
|
? renderTrigger({
|
||||||
inputElem,
|
inputElem,
|
||||||
onClick: handleClickTrigger,
|
onClick: handleClickTrigger,
|
||||||
isOpen,
|
isOpen,
|
||||||
}))
|
})
|
||||||
: (
|
: (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -236,8 +230,13 @@ const TimePicker = ({
|
|||||||
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block hover:text-text-secondary')} role="button" aria-label={t('operation.clear', { ns: 'common' })} onClick={handleClear} />
|
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block hover:text-text-secondary')} role="button" aria-label={t('operation.clear', { ns: 'common' })} onClick={handleClear} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PortalToFollowElemTrigger>
|
/>
|
||||||
<PortalToFollowElemContent className={cn('z-50', popupClassName)}>
|
<PopoverContent
|
||||||
|
placement={placement}
|
||||||
|
sideOffset={0}
|
||||||
|
className={popupClassName}
|
||||||
|
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">
|
<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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Header title={title} />
|
<Header title={title} />
|
||||||
@ -258,8 +257,8 @@ const TimePicker = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export type DatePickerProps = {
|
|||||||
onChange: (date: Dayjs | undefined) => void
|
onChange: (date: Dayjs | undefined) => void
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
triggerWrapClassName?: string
|
triggerWrapClassName?: string
|
||||||
renderTrigger?: (props: TriggerProps) => React.ReactNode
|
renderTrigger?: (props: TriggerProps) => React.ReactElement
|
||||||
minuteFilter?: (minutes: string[]) => string[]
|
minuteFilter?: (minutes: string[]) => string[]
|
||||||
popupZIndexClassname?: string
|
popupZIndexClassname?: string
|
||||||
noConfirm?: boolean
|
noConfirm?: boolean
|
||||||
@ -62,7 +62,7 @@ export type TimePickerProps = {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
onChange: (date: Dayjs | undefined) => void
|
onChange: (date: Dayjs | undefined) => void
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
renderTrigger?: (props: TriggerParams) => React.ReactNode
|
renderTrigger?: (props: TriggerParams) => React.ReactElement
|
||||||
title?: string
|
title?: string
|
||||||
minuteFilter?: (minutes: string[]) => string[]
|
minuteFilter?: (minutes: string[]) => string[]
|
||||||
popupClassName?: string
|
popupClassName?: string
|
||||||
|
|||||||
@ -61,7 +61,7 @@ describe('FileUploadSettings (setting-modal)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call onOpen with toggle function when trigger is clicked', () => {
|
it('should call onOpen with true when trigger is clicked', () => {
|
||||||
const onOpen = vi.fn()
|
const onOpen = vi.fn()
|
||||||
renderWithProvider(
|
renderWithProvider(
|
||||||
<FileUploadSettings open={false} onOpen={onOpen}>
|
<FileUploadSettings open={false} onOpen={onOpen}>
|
||||||
@ -71,12 +71,7 @@ describe('FileUploadSettings (setting-modal)', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByText('Upload Settings'))
|
fireEvent.click(screen.getByText('Upload Settings'))
|
||||||
|
|
||||||
expect(onOpen).toHaveBeenCalled()
|
expect(onOpen).toHaveBeenCalledWith(true)
|
||||||
// The toggle function should flip the open state
|
|
||||||
const toggleFn = onOpen.mock.calls[0]![0]
|
|
||||||
expect(typeof toggleFn).toBe('function')
|
|
||||||
expect(toggleFn(false)).toBe(true)
|
|
||||||
expect(toggleFn(true)).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not call onOpen when disabled', () => {
|
it('should not call onOpen when disabled', () => {
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content'
|
import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
|
|
||||||
type FileUploadSettingsProps = {
|
type FileUploadSettingsProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpen: (state: any) => void
|
onOpen: (state: boolean) => void
|
||||||
onChange?: OnFeaturesChange
|
onChange?: OnFeaturesChange
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
@ -25,18 +25,27 @@ const FileUploadSettings = ({
|
|||||||
imageUpload,
|
imageUpload,
|
||||||
}: FileUploadSettingsProps) => {
|
}: FileUploadSettingsProps) => {
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
placement="left"
|
if (disabled)
|
||||||
offset={{
|
return
|
||||||
mainAxis: 32,
|
onOpen(nextOpen)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
<PopoverTrigger
|
||||||
{children}
|
nativeButton={false}
|
||||||
</PortalToFollowElemTrigger>
|
render={(
|
||||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
<div className="flex">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="left"
|
||||||
|
sideOffset={32}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="max-h-[calc(100vh-20px)] w-[360px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
<div className="max-h-[calc(100vh-20px)] w-[360px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
||||||
<SettingContent
|
<SettingContent
|
||||||
imageUpload={imageUpload}
|
imageUpload={imageUpload}
|
||||||
@ -47,8 +56,8 @@ const FileUploadSettings = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default memo(FileUploadSettings)
|
export default memo(FileUploadSettings)
|
||||||
|
|||||||
@ -1,38 +1,17 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import type { Features } from '../../../types'
|
import type { Features } from '../../../types'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { FeaturesProvider } from '../../../context'
|
import { FeaturesProvider } from '../../../context'
|
||||||
import VoiceSettings from '../voice-settings'
|
import VoiceSettings from '../voice-settings'
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
PortalToFollowElem: ({
|
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||||
children,
|
toast: {
|
||||||
placement,
|
success: vi.fn(),
|
||||||
offset,
|
error: vi.fn(),
|
||||||
}: {
|
warning: vi.fn(),
|
||||||
children: React.ReactNode
|
info: vi.fn(),
|
||||||
placement?: string
|
},
|
||||||
offset?: { mainAxis?: number }
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
data-testid="voice-settings-portal"
|
|
||||||
data-placement={placement}
|
|
||||||
data-main-axis={offset?.mainAxis}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemTrigger: ({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
}) => (
|
|
||||||
<div data-testid="voice-settings-trigger" onClick={onClick}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/next/navigation', () => ({
|
vi.mock('@/next/navigation', () => ({
|
||||||
@ -46,6 +25,25 @@ vi.mock('@/service/use-apps', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/switch', () => ({
|
||||||
|
Switch: ({
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
checked?: boolean
|
||||||
|
onCheckedChange?: (checked: boolean) => void
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="switch"
|
||||||
|
data-checked={String(checked)}
|
||||||
|
onClick={() => onCheckedChange?.(!checked)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
const defaultFeatures: Features = {
|
const defaultFeatures: Features = {
|
||||||
moreLikeThis: { enabled: false },
|
moreLikeThis: { enabled: false },
|
||||||
opening: { enabled: false },
|
opening: { enabled: false },
|
||||||
@ -58,7 +56,7 @@ const defaultFeatures: Features = {
|
|||||||
annotationReply: { enabled: false },
|
annotationReply: { enabled: false },
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderWithProvider = (ui: React.ReactNode) => {
|
const renderWithProvider = (ui: ReactNode) => {
|
||||||
return render(
|
return render(
|
||||||
<FeaturesProvider features={defaultFeatures}>
|
<FeaturesProvider features={defaultFeatures}>
|
||||||
{ui}
|
{ui}
|
||||||
@ -101,12 +99,7 @@ describe('VoiceSettings', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByText('Settings'))
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
|
|
||||||
expect(onOpen).toHaveBeenCalled()
|
expect(onOpen).toHaveBeenCalledWith(true)
|
||||||
// The toggle function should flip the open state
|
|
||||||
const toggleFn = onOpen.mock.calls[0]![0]
|
|
||||||
expect(typeof toggleFn).toBe('function')
|
|
||||||
expect(toggleFn(false)).toBe(true)
|
|
||||||
expect(toggleFn(true)).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not call onOpen when disabled and trigger is clicked', () => {
|
it('should not call onOpen when disabled and trigger is clicked', () => {
|
||||||
@ -137,16 +130,13 @@ describe('VoiceSettings', () => {
|
|||||||
|
|
||||||
it('should use top placement and mainAxis 4 when placementLeft is false', () => {
|
it('should use top placement and mainAxis 4 when placementLeft is false', () => {
|
||||||
renderWithProvider(
|
renderWithProvider(
|
||||||
<VoiceSettings open={false} onOpen={vi.fn()} placementLeft={false}>
|
<VoiceSettings open={true} onOpen={vi.fn()} placementLeft={false}>
|
||||||
<button>Settings</button>
|
<button>Settings</button>
|
||||||
</VoiceSettings>,
|
</VoiceSettings>,
|
||||||
)
|
)
|
||||||
|
|
||||||
const portal = screen.getAllByTestId('voice-settings-portal')
|
const content = screen.getByTestId('popover-content')
|
||||||
.find(item => item.hasAttribute('data-main-axis'))
|
expect(content).toHaveAttribute('data-placement', 'top')
|
||||||
|
expect(content).toHaveAttribute('data-side-offset', '4')
|
||||||
expect(portal).toBeDefined()
|
|
||||||
expect(portal)!.toHaveAttribute('data-placement', 'top')
|
|
||||||
expect(portal)!.toHaveAttribute('data-main-axis', '4')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content'
|
import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
|
|
||||||
type VoiceSettingsProps = {
|
type VoiceSettingsProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpen: (state: any) => void
|
onOpen: (state: boolean) => void
|
||||||
onChange?: OnFeaturesChange
|
onChange?: OnFeaturesChange
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
@ -25,23 +25,32 @@ const VoiceSettings = ({
|
|||||||
placementLeft = true,
|
placementLeft = true,
|
||||||
}: VoiceSettingsProps) => {
|
}: VoiceSettingsProps) => {
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
placement={placementLeft ? 'left' : 'top'}
|
if (disabled)
|
||||||
offset={{
|
return
|
||||||
mainAxis: placementLeft ? 32 : 4,
|
onOpen(nextOpen)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
<PopoverTrigger
|
||||||
{children}
|
nativeButton={false}
|
||||||
</PortalToFollowElemTrigger>
|
render={(
|
||||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
<div className="flex">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement={placementLeft ? 'left' : 'top'}
|
||||||
|
sideOffset={placementLeft ? 32 : 4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[360px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
<div className="w-[360px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
||||||
<ParamConfigContent onClose={() => onOpen(false)} onChange={onChange} />
|
<ParamConfigContent onClose={() => onOpen(false)} onChange={onChange} />
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default memo(VoiceSettings)
|
export default memo(VoiceSettings)
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import type { FileUpload } from '@/app/components/base/features/types'
|
import type { FileUpload } from '@/app/components/base/features/types'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { FILE_URL_REGEX } from '../constants'
|
import { FILE_URL_REGEX } from '../constants'
|
||||||
import FileInput from '../file-input'
|
import FileInput from '../file-input'
|
||||||
import { useFile } from '../hooks'
|
import { useFile } from '../hooks'
|
||||||
@ -54,16 +54,16 @@ const FileFromLinkOrLocal = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement="top"
|
|
||||||
offset={4}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
|
<PopoverTrigger render={trigger(open) as React.ReactElement} />
|
||||||
{trigger(open)}
|
<PopoverContent
|
||||||
</PortalToFollowElemTrigger>
|
placement="top"
|
||||||
<PortalToFollowElemContent className="z-1001">
|
sideOffset={4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg">
|
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg">
|
||||||
{
|
{
|
||||||
showFromLink && (
|
showFromLink && (
|
||||||
@ -126,8 +126,8 @@ const FileFromLinkOrLocal = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { TransferMethod } from '@/types/app'
|
import { TransferMethod } from '@/types/app'
|
||||||
import ImageLinkInput from './image-link-input'
|
import ImageLinkInput from './image-link-input'
|
||||||
import Uploader from './uploader'
|
import Uploader from './uploader'
|
||||||
@ -63,29 +63,31 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
|||||||
|
|
||||||
const closePopover = () => setOpen(false)
|
const closePopover = () => setOpen(false)
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (disabled)
|
|
||||||
return
|
|
||||||
|
|
||||||
setOpen(v => !v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
placement="top-start"
|
if (disabled)
|
||||||
|
return
|
||||||
|
setOpen(nextOpen)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
<PopoverTrigger
|
||||||
<button
|
render={(
|
||||||
type="button"
|
<button
|
||||||
disabled={disabled}
|
type="button"
|
||||||
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
disabled={disabled}
|
||||||
>
|
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
|
>
|
||||||
</button>
|
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
|
||||||
</PortalToFollowElemTrigger>
|
</button>
|
||||||
<PortalToFollowElemContent className="z-50">
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="top-start"
|
||||||
|
sideOffset={0}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
|
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
|
||||||
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
|
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
|
||||||
{!!hasUploadFromLocal && (
|
{!!hasUploadFromLocal && (
|
||||||
@ -115,8 +117,8 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { ImageFile, VisionSettings } from '@/types/app'
|
import type { ImageFile, VisionSettings } from '@/types/app'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
Fragment,
|
Fragment,
|
||||||
useEffect,
|
useEffect,
|
||||||
@ -8,11 +13,6 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link03 } from '@/app/components/base/icons/src/vender/line/general'
|
import { Link03 } from '@/app/components/base/icons/src/vender/line/general'
|
||||||
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
|
import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { TransferMethod } from '@/types/app'
|
import { TransferMethod } from '@/types/app'
|
||||||
import { useImageFiles } from './hooks'
|
import { useImageFiles } from './hooks'
|
||||||
import ImageLinkInput from './image-link-input'
|
import ImageLinkInput from './image-link-input'
|
||||||
@ -35,35 +35,38 @@ const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({
|
|||||||
onUpload(imageFile)
|
onUpload(imageFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (disabled)
|
|
||||||
return
|
|
||||||
|
|
||||||
setOpen(v => !v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
placement="top-start"
|
if (disabled)
|
||||||
|
return
|
||||||
|
setOpen(nextOpen)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
<PopoverTrigger
|
||||||
<div className={`
|
render={(
|
||||||
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
<div
|
||||||
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
className={`
|
||||||
`}
|
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
||||||
>
|
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||||
<Link03 className="mr-2 h-4 w-4" />
|
`}
|
||||||
{t('imageUploader.pasteImageLink', { ns: 'common' })}
|
>
|
||||||
</div>
|
<Link03 className="mr-2 h-4 w-4" />
|
||||||
</PortalToFollowElemTrigger>
|
{t('imageUploader.pasteImageLink', { ns: 'common' })}
|
||||||
<PortalToFollowElemContent className="z-10">
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="top-start"
|
||||||
|
sideOffset={0}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg">
|
<div className="w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg">
|
||||||
<ImageLinkInput onUpload={handleUpload} />
|
<ImageLinkInput onUpload={handleUpload} />
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -148,14 +148,17 @@ export const PortalToFollowElemTrigger = (
|
|||||||
}: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement | null>, asChild?: boolean },
|
}: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement | null>, asChild?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const context = usePortalToFollowElemContext()
|
const context = usePortalToFollowElemContext()
|
||||||
const childrenRef = (children as any).props?.ref
|
const childElement = React.isValidElement<{ ref?: React.Ref<HTMLElement | null> }>(children)
|
||||||
|
? children
|
||||||
|
: null
|
||||||
|
const childrenRef = childElement?.props.ref
|
||||||
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
|
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
|
||||||
|
|
||||||
// `asChild` allows the user to pass any element as the anchor
|
// `asChild` allows the user to pass any element as the anchor
|
||||||
if (asChild && React.isValidElement(children)) {
|
if (asChild && childElement) {
|
||||||
const childProps = (children.props ?? {}) as Record<string, unknown>
|
const childProps = (childElement.props ?? {}) as Record<string, unknown>
|
||||||
return React.cloneElement(
|
return React.cloneElement(
|
||||||
children,
|
childElement,
|
||||||
context.getReferenceProps({
|
context.getReferenceProps({
|
||||||
ref,
|
ref,
|
||||||
...props,
|
...props,
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import { act, render, screen } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../../constants'
|
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../../constants'
|
||||||
import ContextBlockComponent from '../component'
|
import ContextBlockComponent from '../component'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
|
||||||
// Mock the hooks used by ContextBlockComponent
|
// Mock the hooks used by ContextBlockComponent
|
||||||
const mockUseSelectOrDelete = vi.fn()
|
const mockUseSelectOrDelete = vi.fn()
|
||||||
const mockUseTrigger = vi.fn()
|
const mockUseTrigger = vi.fn()
|
||||||
@ -223,6 +226,21 @@ describe('ContextBlockComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('User Interactions', () => {
|
describe('User Interactions', () => {
|
||||||
|
it('should keep the popover closed when the trigger prevents the default click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { triggerSetOpen } = defaultSetup()
|
||||||
|
render(
|
||||||
|
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
|
expect(triggerSetOpen).not.toHaveBeenCalled()
|
||||||
|
expect(
|
||||||
|
screen.queryByText('common.promptEditor.context.modal.add'),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should call onAddContext when add button is clicked', async () => {
|
it('should call onAddContext when add button is clicked', async () => {
|
||||||
defaultSetup({ open: true })
|
defaultSetup({ open: true })
|
||||||
const handleAddContext = vi.fn()
|
const handleAddContext = vi.fn()
|
||||||
@ -345,6 +363,29 @@ describe('ContextBlockComponent', () => {
|
|||||||
// Original datasets still there
|
// Original datasets still there
|
||||||
expect(screen.getByText('Dataset A')).toBeInTheDocument()
|
expect(screen.getByText('Dataset A')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should ignore string events from the event emitter', () => {
|
||||||
|
defaultSetup({ open: true })
|
||||||
|
let subscriptionCallback: (v: Record<string, unknown> | string) => void = () => { }
|
||||||
|
mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown> | string) => void) => {
|
||||||
|
subscriptionCallback = cb
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ContextBlockComponent
|
||||||
|
nodeKey="test-key"
|
||||||
|
datasets={mockDatasets}
|
||||||
|
onAddContext={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
subscriptionCallback('ignore-me')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('Dataset A')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Dataset B')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { Dataset } from './index'
|
import type { Dataset } from './index'
|
||||||
|
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||||
|
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants'
|
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants'
|
||||||
import { useSelectOrDelete, useTrigger } from '../../hooks'
|
import { useSelectOrDelete, useTrigger } from '../../hooks'
|
||||||
@ -32,9 +29,12 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
|
|||||||
const { eventEmitter } = useEventEmitterContextContext()
|
const { eventEmitter } = useEventEmitterContextContext()
|
||||||
const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets)
|
const [localDatasets, setLocalDatasets] = useState<Dataset[]>(datasets)
|
||||||
|
|
||||||
eventEmitter?.useSubscription((v: any) => {
|
eventEmitter?.useSubscription((event?: EventEmitterValue) => {
|
||||||
if (v?.type === UPDATE_DATASETS_EVENT_EMITTER)
|
if (typeof event === 'string')
|
||||||
setLocalDatasets(v.payload)
|
return
|
||||||
|
|
||||||
|
if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && Array.isArray(event.payload))
|
||||||
|
setLocalDatasets(event.payload as Dataset[])
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -49,24 +49,31 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
|
|||||||
<span className="mr-1 i-custom-vender-solid-files-file-05 h-[14px] w-[14px]" data-testid="file-icon" />
|
<span className="mr-1 i-custom-vender-solid-files-file-05 h-[14px] w-[14px]" data-testid="file-icon" />
|
||||||
<div className="mr-1 text-xs font-medium">{t('promptEditor.context.item.title', { ns: 'common' })}</div>
|
<div className="mr-1 text-xs font-medium">{t('promptEditor.context.item.title', { ns: 'common' })}</div>
|
||||||
{!canNotAddContext && (
|
{!canNotAddContext && (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 3,
|
|
||||||
alignmentAxis: -147,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger ref={triggerRef}>
|
<PopoverTrigger
|
||||||
<div className={`
|
nativeButton={false}
|
||||||
|
render={(
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded text-[11px] font-semibold
|
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded text-[11px] font-semibold
|
||||||
${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
|
${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
|
||||||
`}>
|
`}
|
||||||
{localDatasets.length}
|
ref={triggerRef}
|
||||||
</div>
|
onClick={e => e.preventDefault()}
|
||||||
</PortalToFollowElemTrigger>
|
>
|
||||||
<PortalToFollowElemContent style={{ zIndex: 100 }}>
|
{localDatasets.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={3}
|
||||||
|
alignOffset={-147}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[360px] rounded-xl bg-white shadow-lg">
|
<div className="w-[360px] rounded-xl bg-white shadow-lg">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||||
@ -95,8 +102,8 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
|
|||||||
{t('promptEditor.context.modal.footer', { ns: 'common' })}
|
{t('promptEditor.context.modal.footer', { ns: 'common' })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { UPDATE_HISTORY_EVENT_EMITTER } from '../../../constants'
|
|||||||
import HistoryBlockComponent from '../component'
|
import HistoryBlockComponent from '../component'
|
||||||
import { DELETE_HISTORY_BLOCK_COMMAND } from '../index'
|
import { DELETE_HISTORY_BLOCK_COMMAND } from '../index'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
|
||||||
type HistoryEventPayload = {
|
type HistoryEventPayload = {
|
||||||
type?: string
|
type?: string
|
||||||
payload?: RoleName
|
payload?: RoleName
|
||||||
@ -109,6 +111,24 @@ describe('HistoryBlockComponent', () => {
|
|||||||
expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument()
|
expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should keep the popover closed when the trigger prevents the default click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const setOpen = vi.fn() as unknown as Dispatch<SetStateAction<boolean>>
|
||||||
|
mockUseTrigger.mockReturnValue(createTriggerHookReturn(false, setOpen))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<HistoryBlockComponent
|
||||||
|
nodeKey="history-node-trigger"
|
||||||
|
onEditRole={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
|
expect(setOpen).not.toHaveBeenCalled()
|
||||||
|
expect(screen.queryByText('common.promptEditor.history.modal.edit')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should call onEditRole when edit action is clicked', async () => {
|
it('should call onEditRole when edit action is clicked', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const onEditRole = vi.fn()
|
const onEditRole = vi.fn()
|
||||||
@ -188,6 +208,29 @@ describe('HistoryBlockComponent', () => {
|
|||||||
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
|
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should ignore string events from the event emitter', () => {
|
||||||
|
mockUseTrigger.mockReturnValue(createTriggerHookReturn(true))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<HistoryBlockComponent
|
||||||
|
nodeKey="history-node-6-string"
|
||||||
|
roleName={createRoleName({
|
||||||
|
user: 'kept-user',
|
||||||
|
assistant: 'kept-assistant',
|
||||||
|
})}
|
||||||
|
onEditRole={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(subscribedHandler).not.toBeNull()
|
||||||
|
act(() => {
|
||||||
|
subscribedHandler?.('ignore-me' as unknown as HistoryEventPayload)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('kept-user')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('kept-assistant')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should render when event emitter is unavailable', () => {
|
it('should render when event emitter is unavailable', () => {
|
||||||
mockUseEventEmitterContextContext.mockReturnValue({
|
mockUseEventEmitterContextContext.mockReturnValue({
|
||||||
eventEmitter: undefined,
|
eventEmitter: undefined,
|
||||||
|
|||||||
@ -1,16 +1,13 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { RoleName } from './index'
|
import type { RoleName } from './index'
|
||||||
|
import type { EventEmitterValue } from '@/context/event-emitter'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiMoreFill,
|
RiMoreFill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||||
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
|
import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants'
|
||||||
import { useSelectOrDelete, useTrigger } from '../../hooks'
|
import { useSelectOrDelete, useTrigger } from '../../hooks'
|
||||||
@ -33,9 +30,12 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
|
|||||||
const { eventEmitter } = useEventEmitterContextContext()
|
const { eventEmitter } = useEventEmitterContextContext()
|
||||||
const [localRoleName, setLocalRoleName] = useState<RoleName>(roleName)
|
const [localRoleName, setLocalRoleName] = useState<RoleName>(roleName)
|
||||||
|
|
||||||
eventEmitter?.useSubscription((v: any) => {
|
eventEmitter?.useSubscription((event?: EventEmitterValue) => {
|
||||||
if (v?.type === UPDATE_HISTORY_EVENT_EMITTER)
|
if (typeof event === 'string')
|
||||||
setLocalRoleName(v.payload)
|
return
|
||||||
|
|
||||||
|
if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload && typeof event.payload === 'object')
|
||||||
|
setLocalRoleName(event.payload as RoleName)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -49,25 +49,31 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
|
|||||||
>
|
>
|
||||||
<MessageClockCircle className="mr-1 h-[14px] w-[14px]" />
|
<MessageClockCircle className="mr-1 h-[14px] w-[14px]" />
|
||||||
<div className="mr-1 text-xs font-medium">{t('promptEditor.history.item.title', { ns: 'common' })}</div>
|
<div className="mr-1 text-xs font-medium">{t('promptEditor.history.item.title', { ns: 'common' })}</div>
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="top-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
alignmentAxis: -148,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger ref={triggerRef}>
|
<PopoverTrigger
|
||||||
<div className={`
|
nativeButton={false}
|
||||||
|
render={(
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded
|
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded
|
||||||
${open ? 'bg-[#DD2590] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
|
${open ? 'bg-[#DD2590] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
|
||||||
`}
|
`}
|
||||||
>
|
ref={triggerRef}
|
||||||
<RiMoreFill className="h-3 w-3" />
|
onClick={e => e.preventDefault()}
|
||||||
</div>
|
>
|
||||||
</PortalToFollowElemTrigger>
|
<RiMoreFill className="h-3 w-3" />
|
||||||
<PortalToFollowElemContent style={{ zIndex: 100 }}>
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="top-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={-148}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[360px] rounded-xl bg-white shadow-lg">
|
<div className="w-[360px] rounded-xl bg-white shadow-lg">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-2 text-xs font-medium text-gray-500">{t('promptEditor.history.modal.title', { ns: 'common' })}</div>
|
<div className="mb-2 text-xs font-medium text-gray-500">{t('promptEditor.history.modal.title', { ns: 'common' })}</div>
|
||||||
@ -87,8 +93,8 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
|
|||||||
{t('promptEditor.history.modal.edit', { ns: 'common' })}
|
{t('promptEditor.history.modal.edit', { ns: 'common' })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,34 +5,7 @@ import * as React from 'react'
|
|||||||
import { ChunkingMode, DataSourceType } from '@/models/datasets'
|
import { ChunkingMode, DataSourceType } from '@/models/datasets'
|
||||||
import DocumentPicker from '../index'
|
import DocumentPicker from '../index'
|
||||||
|
|
||||||
// Mock portal-to-follow-elem - always render content for testing
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
||||||
PortalToFollowElem: ({ children, open }: {
|
|
||||||
children: React.ReactNode
|
|
||||||
open?: boolean
|
|
||||||
}) => (
|
|
||||||
<div data-testid="portal-elem" data-open={String(open || false)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
}) => (
|
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
// Always render content to allow testing document selection
|
|
||||||
PortalToFollowElemContent: ({ children, className }: {
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
}) => (
|
|
||||||
<div data-testid="portal-content" className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock useDocumentList hook with controllable return value
|
// Mock useDocumentList hook with controllable return value
|
||||||
let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined
|
let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined
|
||||||
@ -152,6 +125,10 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof DocumentPick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openPopover = () => {
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
}
|
||||||
|
|
||||||
describe('DocumentPicker', () => {
|
describe('DocumentPicker', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@ -165,7 +142,7 @@ describe('DocumentPicker', () => {
|
|||||||
it('should render without crashing', () => {
|
it('should render without crashing', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render document name when provided', () => {
|
it('should render document name when provided', () => {
|
||||||
@ -273,7 +250,7 @@ describe('DocumentPicker', () => {
|
|||||||
onChange,
|
onChange,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle value with all fields', () => {
|
it('should handle value with all fields', () => {
|
||||||
@ -318,13 +295,13 @@ describe('DocumentPicker', () => {
|
|||||||
it('should initialize with popup closed', () => {
|
it('should initialize with popup closed', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
|
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open popup when trigger is clicked', () => {
|
it('should open popup when trigger is clicked', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
|
|
||||||
// Verify click handler is called
|
// Verify click handler is called
|
||||||
@ -430,7 +407,7 @@ describe('DocumentPicker', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The component should use the new callback
|
// The component should use the new callback
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should memoize handleChange callback with useCallback', () => {
|
it('should memoize handleChange callback with useCallback', () => {
|
||||||
@ -440,7 +417,7 @@ describe('DocumentPicker', () => {
|
|||||||
renderComponent({ onChange })
|
renderComponent({ onChange })
|
||||||
|
|
||||||
// Verify component renders correctly, callback memoization is internal
|
// Verify component renders correctly, callback memoization is internal
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -518,7 +495,7 @@ describe('DocumentPicker', () => {
|
|||||||
it('should toggle popup when trigger is clicked', () => {
|
it('should toggle popup when trigger is clicked', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
|
|
||||||
// Trigger click should be handled
|
// Trigger click should be handled
|
||||||
@ -591,7 +568,7 @@ describe('DocumentPicker', () => {
|
|||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
// When loading, component should still render without crashing
|
// When loading, component should still render without crashing
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fetch documents on mount', () => {
|
it('should fetch documents on mount', () => {
|
||||||
@ -611,7 +588,7 @@ describe('DocumentPicker', () => {
|
|||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
// Component should render without crashing
|
// Component should render without crashing
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle undefined data response', () => {
|
it('should handle undefined data response', () => {
|
||||||
@ -620,7 +597,7 @@ describe('DocumentPicker', () => {
|
|||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
// Should not crash
|
// Should not crash
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -732,13 +709,13 @@ describe('DocumentPicker', () => {
|
|||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
// Should not crash
|
// Should not crash
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle rapid toggle clicks', () => {
|
it('should handle rapid toggle clicks', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
|
|
||||||
// Rapid clicks
|
// Rapid clicks
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
@ -795,7 +772,7 @@ describe('DocumentPicker', () => {
|
|||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
// Should not crash
|
// Should not crash
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle document list mapping with various data_source_detail_dict states', () => {
|
it('should handle document list mapping with various data_source_detail_dict states', () => {
|
||||||
@ -819,7 +796,7 @@ describe('DocumentPicker', () => {
|
|||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
// Should not crash during mapping
|
// Should not crash during mapping
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -829,13 +806,13 @@ describe('DocumentPicker', () => {
|
|||||||
it('should handle empty datasetId', () => {
|
it('should handle empty datasetId', () => {
|
||||||
renderComponent({ datasetId: '' })
|
renderComponent({ datasetId: '' })
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle UUID format datasetId', () => {
|
it('should handle UUID format datasetId', () => {
|
||||||
renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' })
|
renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' })
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -926,6 +903,7 @@ describe('DocumentPicker', () => {
|
|||||||
const onChange = vi.fn()
|
const onChange = vi.fn()
|
||||||
|
|
||||||
renderComponent({ onChange })
|
renderComponent({ onChange })
|
||||||
|
openPopover()
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Document 2'))
|
fireEvent.click(screen.getByText('Document 2'))
|
||||||
|
|
||||||
@ -939,6 +917,7 @@ describe('DocumentPicker', () => {
|
|||||||
mockDocumentListData = { data: docs }
|
mockDocumentListData = { data: docs }
|
||||||
|
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
openPopover()
|
||||||
|
|
||||||
// Documents should be rendered in the list
|
// Documents should be rendered in the list
|
||||||
expect(screen.getByText('Document 1')).toBeInTheDocument()
|
expect(screen.getByText('Document 1')).toBeInTheDocument()
|
||||||
@ -978,14 +957,14 @@ describe('DocumentPicker', () => {
|
|||||||
|
|
||||||
// The mapping: d.data_source_detail_dict?.upload_file?.extension || ''
|
// The mapping: d.data_source_detail_dict?.upload_file?.extension || ''
|
||||||
// Should extract 'pdf' from the document
|
// Should extract 'pdf' from the document
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render trigger with SearchInput integration', () => {
|
it('should render trigger with SearchInput integration', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
// The trigger is always rendered
|
// The trigger is always rendered
|
||||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should integrate FileIcon component', () => {
|
it('should integrate FileIcon component', () => {
|
||||||
@ -1001,7 +980,7 @@ describe('DocumentPicker', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// FileIcon should render an SVG icon for the file extension
|
// FileIcon should render an SVG icon for the file extension
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1010,9 +989,10 @@ describe('DocumentPicker', () => {
|
|||||||
describe('Visual States', () => {
|
describe('Visual States', () => {
|
||||||
it('should render portal content for document selection', () => {
|
it('should render portal content for document selection', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
openPopover()
|
||||||
|
|
||||||
// Portal content is rendered in our mock for testing
|
// Popover content is rendered after opening the trigger in our mock
|
||||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,34 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import PreviewDocumentPicker from '../preview-document-picker'
|
import PreviewDocumentPicker from '../preview-document-picker'
|
||||||
|
|
||||||
// Mock portal-to-follow-elem - always render content for testing
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
||||||
PortalToFollowElem: ({ children, open }: {
|
|
||||||
children: React.ReactNode
|
|
||||||
open?: boolean
|
|
||||||
}) => (
|
|
||||||
<div data-testid="portal-elem" data-open={String(open || false)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
}) => (
|
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
// Always render content to allow testing document selection
|
|
||||||
PortalToFollowElemContent: ({ children, className }: {
|
|
||||||
children: React.ReactNode
|
|
||||||
className?: string
|
|
||||||
}) => (
|
|
||||||
<div data-testid="portal-content" className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Factory function to create mock DocumentItem
|
// Factory function to create mock DocumentItem
|
||||||
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
|
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
|
||||||
@ -67,6 +40,10 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof PreviewDocum
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openPopover = () => {
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
}
|
||||||
|
|
||||||
describe('PreviewDocumentPicker', () => {
|
describe('PreviewDocumentPicker', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@ -77,7 +54,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
it('should render without crashing', () => {
|
it('should render without crashing', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render document name from value prop', () => {
|
it('should render document name from value prop', () => {
|
||||||
@ -110,7 +87,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
files: [], // Use empty files to avoid duplicate icons
|
files: [], // Use empty files to avoid duplicate icons
|
||||||
})
|
})
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -120,7 +97,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
files: [], // Use empty files to avoid duplicate icons
|
files: [], // Use empty files to avoid duplicate icons
|
||||||
})
|
})
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -131,22 +108,21 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
const props = createDefaultProps()
|
const props = createDefaultProps()
|
||||||
render(<PreviewDocumentPicker {...props} />)
|
render(<PreviewDocumentPicker {...props} />)
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply className to trigger element', () => {
|
it('should apply className to trigger element', () => {
|
||||||
renderComponent({ className: 'custom-class' })
|
renderComponent({ className: 'custom-class' })
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
const innerDiv = trigger.querySelector('.custom-class')
|
expect(trigger).toHaveClass('custom-class')
|
||||||
expect(innerDiv).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle empty files array', () => {
|
it('should handle empty files array', () => {
|
||||||
// Component should render without crashing with empty files
|
// Component should render without crashing with empty files
|
||||||
renderComponent({ files: [] })
|
renderComponent({ files: [] })
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle single file', () => {
|
it('should handle single file', () => {
|
||||||
@ -155,7 +131,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })],
|
files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle multiple files', () => {
|
it('should handle multiple files', () => {
|
||||||
@ -164,7 +140,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
files: createMockDocumentList(5),
|
files: createMockDocumentList(5),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use value.extension for file icon', () => {
|
it('should use value.extension for file icon', () => {
|
||||||
@ -172,7 +148,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
|
value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -182,13 +158,13 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
it('should initialize with popup closed', () => {
|
it('should initialize with popup closed', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false')
|
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should toggle popup when trigger is clicked', () => {
|
it('should toggle popup when trigger is clicked', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
|
|
||||||
expect(trigger).toBeInTheDocument()
|
expect(trigger).toBeInTheDocument()
|
||||||
@ -196,9 +172,10 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
|
|
||||||
it('should render portal content for document selection', () => {
|
it('should render portal content for document selection', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
openPopover()
|
||||||
|
|
||||||
// Portal content is always rendered in our mock for testing
|
// Popover content is rendered after opening the trigger in our mock
|
||||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
expect(screen.getByTestId('popover-content')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -242,7 +219,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />,
|
<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -265,7 +242,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
|
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -274,7 +251,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
it('should toggle popup when trigger is clicked', () => {
|
it('should toggle popup when trigger is clicked', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
|
|
||||||
expect(trigger).toBeInTheDocument()
|
expect(trigger).toBeInTheDocument()
|
||||||
@ -283,6 +260,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
it('should render document list with files', () => {
|
it('should render document list with files', () => {
|
||||||
const files = createMockDocumentList(3)
|
const files = createMockDocumentList(3)
|
||||||
renderComponent({ files })
|
renderComponent({ files })
|
||||||
|
openPopover()
|
||||||
|
|
||||||
// Documents should be visible in the list
|
// Documents should be visible in the list
|
||||||
expect(screen.getByText('Document 1')).toBeInTheDocument()
|
expect(screen.getByText('Document 1')).toBeInTheDocument()
|
||||||
@ -295,6 +273,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
const files = createMockDocumentList(3)
|
const files = createMockDocumentList(3)
|
||||||
|
|
||||||
renderComponent({ files, onChange })
|
renderComponent({ files, onChange })
|
||||||
|
openPopover()
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Document 2'))
|
fireEvent.click(screen.getByText('Document 2'))
|
||||||
|
|
||||||
@ -306,7 +285,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
it('should handle rapid toggle clicks', () => {
|
it('should handle rapid toggle clicks', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
|
|
||||||
// Rapid clicks
|
// Rapid clicks
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
@ -337,14 +316,14 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
// Renders placeholder for missing name
|
// Renders placeholder for missing name
|
||||||
expect(screen.getByText('--')).toBeInTheDocument()
|
expect(screen.getByText('--')).toBeInTheDocument()
|
||||||
// Portal wrapper renders
|
// Portal wrapper renders
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle empty files array', () => {
|
it('should handle empty files array', () => {
|
||||||
renderComponent({ files: [] })
|
renderComponent({ files: [] })
|
||||||
|
|
||||||
// Component should render without crashing
|
// Component should render without crashing
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle very long document names', () => {
|
it('should handle very long document names', () => {
|
||||||
@ -374,7 +353,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
render(<PreviewDocumentPicker {...props} />)
|
render(<PreviewDocumentPicker {...props} />)
|
||||||
|
|
||||||
// Component should render without crashing
|
// Component should render without crashing
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle large number of files', () => {
|
it('should handle large number of files', () => {
|
||||||
@ -382,7 +361,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
renderComponent({ files: manyFiles })
|
renderComponent({ files: manyFiles })
|
||||||
|
|
||||||
// Component should accept large files array
|
// Component should accept large files array
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle files with same name but different extensions', () => {
|
it('should handle files with same name but different extensions', () => {
|
||||||
@ -393,7 +372,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
renderComponent({ files })
|
renderComponent({ files })
|
||||||
|
|
||||||
// Component should handle duplicate names
|
// Component should handle duplicate names
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -427,7 +406,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
files: [createMockDocumentItem({ name: 'Single' })],
|
files: [createMockDocumentItem({ name: 'Single' })],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle two files', () => {
|
it('should handle two files', () => {
|
||||||
@ -435,7 +414,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
files: createMockDocumentList(2),
|
files: createMockDocumentList(2),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle many files', () => {
|
it('should handle many files', () => {
|
||||||
@ -443,7 +422,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
files: createMockDocumentList(50),
|
files: createMockDocumentList(50),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -451,23 +430,22 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
it('should apply custom className', () => {
|
it('should apply custom className', () => {
|
||||||
renderComponent({ className: 'my-custom-class' })
|
renderComponent({ className: 'my-custom-class' })
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
expect(trigger.querySelector('.my-custom-class')).toBeInTheDocument()
|
expect(trigger).toHaveClass('my-custom-class')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should work without className', () => {
|
it('should work without className', () => {
|
||||||
renderComponent({ className: undefined })
|
renderComponent({ className: undefined })
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
|
expect(screen.getByTestId('popover-trigger')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle multiple class names', () => {
|
it('should handle multiple class names', () => {
|
||||||
renderComponent({ className: 'class-one class-two' })
|
renderComponent({ className: 'class-one class-two' })
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
const element = trigger.querySelector('.class-one')
|
expect(trigger).toHaveClass('class-one')
|
||||||
expect(element).toBeInTheDocument()
|
expect(trigger).toHaveClass('class-two')
|
||||||
expect(element).toHaveClass('class-two')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -480,7 +458,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
files: [], // Use empty files to avoid duplicate icons
|
files: [], // Use empty files to avoid duplicate icons
|
||||||
})
|
})
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -491,6 +469,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
it('should render all documents in the list', () => {
|
it('should render all documents in the list', () => {
|
||||||
const files = createMockDocumentList(5)
|
const files = createMockDocumentList(5)
|
||||||
renderComponent({ files })
|
renderComponent({ files })
|
||||||
|
openPopover()
|
||||||
|
|
||||||
// All documents should be visible
|
// All documents should be visible
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
@ -503,6 +482,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
const files = createMockDocumentList(3)
|
const files = createMockDocumentList(3)
|
||||||
|
|
||||||
renderComponent({ files, onChange })
|
renderComponent({ files, onChange })
|
||||||
|
openPopover()
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Document 1'))
|
fireEvent.click(screen.getByText('Document 1'))
|
||||||
|
|
||||||
@ -528,6 +508,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
onChange={vi.fn()}
|
onChange={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
openPopover()
|
||||||
expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument()
|
expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -537,9 +518,8 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
it('should apply hover styles on trigger', () => {
|
it('should apply hover styles on trigger', () => {
|
||||||
renderComponent()
|
renderComponent()
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
const innerDiv = trigger.querySelector('.hover\\:bg-state-base-hover')
|
expect(trigger).toHaveClass('hover:bg-state-base-hover')
|
||||||
expect(innerDiv).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have truncate class for long names', () => {
|
it('should have truncate class for long names', () => {
|
||||||
@ -568,6 +548,7 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
const files = createMockDocumentList(3)
|
const files = createMockDocumentList(3)
|
||||||
|
|
||||||
renderComponent({ files, onChange })
|
renderComponent({ files, onChange })
|
||||||
|
openPopover()
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Document 1'))
|
fireEvent.click(screen.getByText('Document 1'))
|
||||||
|
|
||||||
@ -582,10 +563,12 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
renderComponent({ files: customFiles, onChange })
|
renderComponent({ files: customFiles, onChange })
|
||||||
|
openPopover()
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Custom File 1'))
|
fireEvent.click(screen.getByText('Custom File 1'))
|
||||||
expect(onChange).toHaveBeenCalledWith(customFiles[0])
|
expect(onChange).toHaveBeenCalledWith(customFiles[0])
|
||||||
|
|
||||||
|
openPopover()
|
||||||
fireEvent.click(screen.getByText('Custom File 2'))
|
fireEvent.click(screen.getByText('Custom File 2'))
|
||||||
expect(onChange).toHaveBeenCalledWith(customFiles[1])
|
expect(onChange).toHaveBeenCalledWith(customFiles[1])
|
||||||
})
|
})
|
||||||
@ -597,8 +580,11 @@ describe('PreviewDocumentPicker', () => {
|
|||||||
renderComponent({ files, onChange })
|
renderComponent({ files, onChange })
|
||||||
|
|
||||||
// Select multiple documents sequentially
|
// Select multiple documents sequentially
|
||||||
|
openPopover()
|
||||||
fireEvent.click(screen.getByText('Document 1'))
|
fireEvent.click(screen.getByText('Document 1'))
|
||||||
|
openPopover()
|
||||||
fireEvent.click(screen.getByText('Document 3'))
|
fireEvent.click(screen.getByText('Document 3'))
|
||||||
|
openPopover()
|
||||||
fireEvent.click(screen.getByText('Document 2'))
|
fireEvent.click(screen.getByText('Document 2'))
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(3)
|
expect(onChange).toHaveBeenCalledTimes(3)
|
||||||
|
|||||||
@ -2,6 +2,11 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets'
|
import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { RiArrowDownSLine } from '@remixicon/react'
|
import { RiArrowDownSLine } from '@remixicon/react'
|
||||||
import { useBoolean } from 'ahooks'
|
import { useBoolean } from 'ahooks'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
@ -9,11 +14,6 @@ import { useCallback, useMemo, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
|
import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import SearchInput from '@/app/components/base/search-input'
|
import SearchInput from '@/app/components/base/search-input'
|
||||||
import { ChunkingMode } from '@/models/datasets'
|
import { ChunkingMode } from '@/models/datasets'
|
||||||
import { useDocumentList } from '@/service/knowledge/use-document'
|
import { useDocumentList } from '@/service/knowledge/use-document'
|
||||||
@ -61,7 +61,6 @@ const DocumentPicker: FC<Props> = ({
|
|||||||
|
|
||||||
const [open, {
|
const [open, {
|
||||||
set: setOpen,
|
set: setOpen,
|
||||||
toggle: togglePopup,
|
|
||||||
}] = useBoolean(false)
|
}] = useBoolean(false)
|
||||||
const ArrowIcon = RiArrowDownSLine
|
const ArrowIcon = RiArrowDownSLine
|
||||||
|
|
||||||
@ -77,34 +76,40 @@ const DocumentPicker: FC<Props> = ({
|
|||||||
}, [parentMode, t])
|
}, [parentMode, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-start"
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={togglePopup}>
|
<PopoverTrigger
|
||||||
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
nativeButton={false}
|
||||||
<FileIcon name={name} extension={extension} size="xl" />
|
render={(
|
||||||
<div className="mr-0.5 ml-1 flex flex-col items-start">
|
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||||
<div className="flex items-center space-x-0.5">
|
<FileIcon name={name} extension={extension} size="xl" />
|
||||||
<span className={cn('system-md-semibold text-text-primary')}>
|
<div className="mr-0.5 ml-1 flex flex-col items-start">
|
||||||
{' '}
|
<div className="flex items-center space-x-0.5">
|
||||||
{name || '--'}
|
<span className={cn('system-md-semibold text-text-primary')}>
|
||||||
</span>
|
{' '}
|
||||||
<ArrowIcon className="h-4 w-4 text-text-primary" />
|
{name || '--'}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
|
<ArrowIcon className="h-4 w-4 text-text-primary" />
|
||||||
<TypeIcon className="h-3 w-3" />
|
</div>
|
||||||
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
|
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
|
||||||
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
|
<TypeIcon className="h-3 w-3" />
|
||||||
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
|
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
|
||||||
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
|
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
|
||||||
</span>
|
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
|
||||||
|
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</PortalToFollowElemTrigger>
|
/>
|
||||||
<PortalToFollowElemContent className="z-11">
|
<PopoverContent
|
||||||
|
placement="bottom-start"
|
||||||
|
sideOffset={0}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pt-2 shadow-lg backdrop-blur-[5px]">
|
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pt-2 shadow-lg backdrop-blur-[5px]">
|
||||||
<SearchInput value={query} onChange={setQuery} className="mx-1" />
|
<SearchInput value={query} onChange={setQuery} className="mx-1" />
|
||||||
{documentsList
|
{documentsList
|
||||||
@ -125,9 +130,8 @@ const DocumentPicker: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
</PortalToFollowElemContent>
|
</Popover>
|
||||||
</PortalToFollowElem>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default React.memo(DocumentPicker)
|
export default React.memo(DocumentPicker)
|
||||||
|
|||||||
@ -2,17 +2,17 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { DocumentItem } from '@/models/datasets'
|
import type { DocumentItem } from '@/models/datasets'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { RiArrowDownSLine } from '@remixicon/react'
|
import { RiArrowDownSLine } from '@remixicon/react'
|
||||||
import { useBoolean } from 'ahooks'
|
import { useBoolean } from 'ahooks'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import FileIcon from '../document-file-icon'
|
import FileIcon from '../document-file-icon'
|
||||||
import DocumentList from './document-list'
|
import DocumentList from './document-list'
|
||||||
|
|
||||||
@ -35,7 +35,6 @@ const PreviewDocumentPicker: FC<Props> = ({
|
|||||||
|
|
||||||
const [open, {
|
const [open, {
|
||||||
set: setOpen,
|
set: setOpen,
|
||||||
toggle: togglePopup,
|
|
||||||
}] = useBoolean(false)
|
}] = useBoolean(false)
|
||||||
const ArrowIcon = RiArrowDownSLine
|
const ArrowIcon = RiArrowDownSLine
|
||||||
|
|
||||||
@ -45,27 +44,32 @@ const PreviewDocumentPicker: FC<Props> = ({
|
|||||||
}, [onChange, setOpen])
|
}, [onChange, setOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-start"
|
|
||||||
offset={4}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={togglePopup}>
|
<PopoverTrigger
|
||||||
<div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover', className)}>
|
nativeButton={false}
|
||||||
<FileIcon name={name} extension={extension} size="lg" />
|
render={(
|
||||||
<div className="ml-1 flex flex-col items-start">
|
<div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover', className)}>
|
||||||
<div className="flex items-center space-x-0.5">
|
<FileIcon name={name} extension={extension} size="lg" />
|
||||||
<span className={cn('max-w-[200px] truncate system-md-semibold text-text-primary')}>
|
<div className="ml-1 flex flex-col items-start">
|
||||||
{' '}
|
<div className="flex items-center space-x-0.5">
|
||||||
{name || '--'}
|
<span className={cn('max-w-[200px] truncate system-md-semibold text-text-primary')}>
|
||||||
</span>
|
{' '}
|
||||||
<ArrowIcon className="h-[18px] w-[18px] text-text-primary" />
|
{name || '--'}
|
||||||
|
</span>
|
||||||
|
<ArrowIcon className="h-[18px] w-[18px] text-text-primary" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</PortalToFollowElemTrigger>
|
/>
|
||||||
<PortalToFollowElemContent className="z-11">
|
<PopoverContent
|
||||||
|
placement="bottom-start"
|
||||||
|
sideOffset={4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[392px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
|
<div className="w-[392px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
|
||||||
{files?.length > 1 && <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary">{t('preprocessDocument', { ns: 'dataset', num: files.length })}</div>}
|
{files?.length > 1 && <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary">{t('preprocessDocument', { ns: 'dataset', num: files.length })}</div>}
|
||||||
{files?.length > 0
|
{files?.length > 0
|
||||||
@ -81,9 +85,8 @@ const PreviewDocumentPicker: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
</PortalToFollowElemContent>
|
</Popover>
|
||||||
</PortalToFollowElem>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default React.memo(PreviewDocumentPicker)
|
export default React.memo(PreviewDocumentPicker)
|
||||||
|
|||||||
@ -231,8 +231,9 @@ describe('StepTwoPreview', () => {
|
|||||||
describe('Props Passing', () => {
|
describe('Props Passing', () => {
|
||||||
it('should render preview button when isIdle is true', () => {
|
it('should render preview button when isIdle is true', () => {
|
||||||
render(<StepTwoPreview {...defaultProps} isIdle={true} />)
|
render(<StepTwoPreview {...defaultProps} isIdle={true} />)
|
||||||
// ChunkPreview shows a preview button when idle
|
const previewButton = screen.getByRole('button', {
|
||||||
const previewButton = screen.queryByRole('button')
|
name: 'datasetPipeline.addDocuments.stepTwo.previewChunks',
|
||||||
|
})
|
||||||
expect(previewButton).toBeInTheDocument()
|
expect(previewButton).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -240,13 +241,13 @@ describe('StepTwoPreview', () => {
|
|||||||
const onPreview = vi.fn()
|
const onPreview = vi.fn()
|
||||||
render(<StepTwoPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
|
render(<StepTwoPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
|
||||||
|
|
||||||
// Find and click the preview button
|
const previewButton = screen.getByRole('button', {
|
||||||
const buttons = screen.getAllByRole('button')
|
name: 'datasetPipeline.addDocuments.stepTwo.previewChunks',
|
||||||
const previewButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('preview'))
|
})
|
||||||
if (previewButton) {
|
|
||||||
previewButton.click()
|
previewButton.click()
|
||||||
expect(onPreview).toHaveBeenCalled()
|
|
||||||
}
|
expect(onPreview).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import type { Member } from '@/models/common'
|
import type { Member } from '@/models/common'
|
||||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
|
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
|
||||||
import { useDebounceFn } from 'ahooks'
|
import { useDebounceFn } from 'ahooks'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||||
import { DatasetPermission } from '@/models/datasets'
|
import { DatasetPermission } from '@/models/datasets'
|
||||||
import MemberItem from './member-item'
|
import MemberItem from './member-item'
|
||||||
@ -90,93 +90,98 @@ const PermissionSelector = ({
|
|||||||
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
|
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
placement="bottom-start"
|
if (disabled)
|
||||||
offset={4}
|
return
|
||||||
|
setOpen(nextOpen)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
onClick={() => !disabled && setOpen(v => !v)}
|
render={(
|
||||||
className="block"
|
<div className={cn('flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
|
||||||
>
|
{
|
||||||
<div className={cn('flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
|
isOnlyMe && (
|
||||||
{
|
<>
|
||||||
isOnlyMe && (
|
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||||
<>
|
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
</div>
|
||||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||||
</div>
|
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
|
||||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
</div>
|
||||||
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
|
</>
|
||||||
</div>
|
)
|
||||||
</>
|
}
|
||||||
)
|
{
|
||||||
}
|
isAllTeamMembers && (
|
||||||
{
|
<>
|
||||||
isAllTeamMembers && (
|
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||||
<>
|
<RiGroup2Line className="size-4 text-text-secondary" />
|
||||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
</div>
|
||||||
<RiGroup2Line className="size-4 text-text-secondary" />
|
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||||
</div>
|
{t('form.permissionsAllMember', { ns: 'datasetSettings' })}
|
||||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
</div>
|
||||||
{t('form.permissionsAllMember', { ns: 'datasetSettings' })}
|
</>
|
||||||
</div>
|
)
|
||||||
</>
|
}
|
||||||
)
|
{
|
||||||
}
|
isPartialMembers && (
|
||||||
{
|
<>
|
||||||
isPartialMembers && (
|
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
||||||
<>
|
{
|
||||||
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
selectedMembers.length === 1 && (
|
||||||
{
|
|
||||||
selectedMembers.length === 1 && (
|
|
||||||
<Avatar
|
|
||||||
avatar={selectedMembers[0]!.avatar_url}
|
|
||||||
name={selectedMembers[0]!.name}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
selectedMembers.length >= 2 && (
|
|
||||||
<>
|
|
||||||
<Avatar
|
<Avatar
|
||||||
avatar={selectedMembers[0]!.avatar_url}
|
avatar={selectedMembers[0]!.avatar_url}
|
||||||
name={selectedMembers[0]!.name}
|
name={selectedMembers[0]!.name}
|
||||||
className="absolute top-0 left-0 z-0"
|
size="xs"
|
||||||
size="xxs"
|
|
||||||
/>
|
/>
|
||||||
<Avatar
|
)
|
||||||
avatar={selectedMembers[1]!.avatar_url}
|
}
|
||||||
name={selectedMembers[1]!.name}
|
{
|
||||||
className="absolute right-0 bottom-0 z-10"
|
selectedMembers.length >= 2 && (
|
||||||
size="xxs"
|
<>
|
||||||
/>
|
<Avatar
|
||||||
</>
|
avatar={selectedMembers[0]!.avatar_url}
|
||||||
)
|
name={selectedMembers[0]!.name}
|
||||||
}
|
className="absolute top-0 left-0 z-0"
|
||||||
</div>
|
size="xxs"
|
||||||
<div
|
/>
|
||||||
title={selectedMemberNames}
|
<Avatar
|
||||||
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
avatar={selectedMembers[1]!.avatar_url}
|
||||||
>
|
name={selectedMembers[1]!.name}
|
||||||
{selectedMemberNames}
|
className="absolute right-0 bottom-0 z-10"
|
||||||
</div>
|
size="xxs"
|
||||||
</>
|
/>
|
||||||
)
|
</>
|
||||||
}
|
)
|
||||||
<RiArrowDownSLine
|
}
|
||||||
className={cn(
|
</div>
|
||||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
<div
|
||||||
open && 'text-text-secondary',
|
title={selectedMemberNames}
|
||||||
disabled && 'text-components-input-text-placeholder!',
|
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
||||||
)}
|
>
|
||||||
/>
|
{selectedMemberNames}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemTrigger>
|
</>
|
||||||
<PortalToFollowElemContent className="z-1002">
|
)
|
||||||
|
}
|
||||||
|
<RiArrowDownSLine
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||||
|
open && 'text-text-secondary',
|
||||||
|
disabled && 'text-components-input-text-placeholder!',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-start"
|
||||||
|
sideOffset={4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
|
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
{/* Only me */}
|
{/* Only me */}
|
||||||
@ -236,6 +241,7 @@ const PermissionSelector = ({
|
|||||||
)}
|
)}
|
||||||
{filteredMemberList.map(member => (
|
{filteredMemberList.map(member => (
|
||||||
<MemberItem
|
<MemberItem
|
||||||
|
key={member.id}
|
||||||
leftIcon={
|
leftIcon={
|
||||||
<Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" />
|
<Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" />
|
||||||
}
|
}
|
||||||
@ -256,9 +262,9 @@ const PermissionSelector = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,8 @@ vi.mock('@/service/use-common', () => ({
|
|||||||
useApiBasedExtensions: vi.fn(),
|
useApiBasedExtensions: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
|
||||||
describe('ApiBasedExtensionSelector', () => {
|
describe('ApiBasedExtensionSelector', () => {
|
||||||
const mockOnChange = vi.fn()
|
const mockOnChange = vi.fn()
|
||||||
const mockSetShowAccountSettingModal = vi.fn()
|
const mockSetShowAccountSettingModal = vi.fn()
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiAddLine,
|
RiAddLine,
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
@ -8,11 +9,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
} from '@/app/components/base/icons/src/vender/line/arrows'
|
} from '@/app/components/base/icons/src/vender/line/arrows'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||||
import { useModalContext } from '@/context/modal-context'
|
import { useModalContext } from '@/context/modal-context'
|
||||||
import { useApiBasedExtensions } from '@/service/use-common'
|
import { useApiBasedExtensions } from '@/service/use-common'
|
||||||
@ -41,35 +37,42 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
|
|||||||
const currentItem = data?.find(item => item.id === value)
|
const currentItem = data?.find(item => item.id === value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-start"
|
|
||||||
offset={4}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} className="w-full">
|
<PopoverTrigger
|
||||||
{
|
render={(
|
||||||
currentItem
|
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
|
||||||
? (
|
{
|
||||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
|
currentItem
|
||||||
<div className="text-sm text-text-primary">{currentItem.name}</div>
|
? (
|
||||||
<div className="flex items-center">
|
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
|
||||||
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
|
<div className="text-sm text-text-primary">{currentItem.name}</div>
|
||||||
{currentItem.api_endpoint}
|
<div className="flex items-center">
|
||||||
|
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
|
||||||
|
{currentItem.api_endpoint}
|
||||||
|
</div>
|
||||||
|
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
)
|
||||||
</div>
|
: (
|
||||||
</div>
|
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
|
||||||
)
|
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
|
||||||
: (
|
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
|
</div>
|
||||||
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
|
)
|
||||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
}
|
||||||
</div>
|
</button>
|
||||||
)
|
)}
|
||||||
}
|
/>
|
||||||
</PortalToFollowElemTrigger>
|
<PopoverContent
|
||||||
<PortalToFollowElemContent className="z-1002 w-[calc(100%-32px)] max-w-[576px]">
|
placement="bottom-start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="w-[calc(100%-32px)] max-w-[576px]"
|
||||||
|
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||||
|
>
|
||||||
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||||
<div className="p-1">
|
<div className="p-1">
|
||||||
<div className="flex items-center justify-between px-3 pt-2 pb-1">
|
<div className="flex items-center justify-between px-3 pt-2 pb-1">
|
||||||
@ -116,8 +119,8 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
import type { DataSourceAuth } from '../types'
|
import type { DataSourceAuth } from '../types'
|
||||||
import type { FormSchema } from '@/app/components/base/form/types'
|
import type { FormSchema } from '@/app/components/base/form/types'
|
||||||
import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types'
|
import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types'
|
||||||
@ -6,6 +7,15 @@ import { FormTypeEnum } from '@/app/components/base/form/types'
|
|||||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
|
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
|
||||||
import Configure from '../configure'
|
import Configure from '../configure'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
|
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||||
|
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
|
||||||
|
<button {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure Component Tests
|
* Configure Component Tests
|
||||||
* Using Unit approach to ensure 100% coverage and stable tests.
|
* Using Unit approach to ensure 100% coverage and stable tests.
|
||||||
|
|||||||
@ -5,6 +5,11 @@ import type {
|
|||||||
PluginPayload,
|
PluginPayload,
|
||||||
} from '@/app/components/plugins/plugin-auth/types'
|
} from '@/app/components/plugins/plugin-auth/types'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiAddLine,
|
RiAddLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
@ -15,11 +20,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import {
|
import {
|
||||||
AddApiKeyButton,
|
AddApiKeyButton,
|
||||||
AddOAuthButton,
|
AddOAuthButton,
|
||||||
@ -56,10 +56,6 @@ const Configure = ({
|
|||||||
}
|
}
|
||||||
}, [pluginPayload, t])
|
}, [pluginPayload, t])
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
|
||||||
setOpen(v => !v)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleUpdate = useCallback(() => {
|
const handleUpdate = useCallback(() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
onUpdate?.()
|
onUpdate?.()
|
||||||
@ -67,24 +63,26 @@ const Configure = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: -4,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
<PopoverTrigger
|
||||||
<Button
|
render={(
|
||||||
variant="secondary-accent"
|
<Button
|
||||||
>
|
variant="secondary-accent"
|
||||||
<RiAddLine className="h-4 w-4" />
|
>
|
||||||
{t('dataSource.configure', { ns: 'common' })}
|
<RiAddLine className="h-4 w-4" />
|
||||||
</Button>
|
{t('dataSource.configure', { ns: 'common' })}
|
||||||
</PortalToFollowElemTrigger>
|
</Button>
|
||||||
<PortalToFollowElemContent className="z-1002">
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={-4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
|
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
|
||||||
{
|
{
|
||||||
!!canOAuth && (
|
!!canOAuth && (
|
||||||
@ -122,8 +120,8 @@ const Configure = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,33 +34,17 @@ vi.mock('@remixicon/react', () => ({
|
|||||||
RiAddLine: () => <div data-testid="add-line-icon" />,
|
RiAddLine: () => <div data-testid="add-line-icon" />,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/tooltip', () => ({
|
vi.mock('@langgenius/dify-ui/tooltip', () => ({
|
||||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
Tooltip: ({ children }: { children: React.ReactNode }) => (
|
||||||
<div data-testid="tooltip-mock">
|
<div data-testid="tooltip-mock">
|
||||||
{children}
|
{children}
|
||||||
<div>{popupContent}</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock portal components to avoid async test DOM issues (consistent with sibling tests)
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
||||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => (
|
|
||||||
<div data-testid="portal" data-open={open}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
|
|
||||||
// In many tests, we need to find elements inside the content even if "closed" in state
|
|
||||||
// but not yet "removed" from DOM. However, to avoid multiple elements issues,
|
|
||||||
// we should be careful.
|
|
||||||
// For AddCustomModel, we need the content to be present when we click a model.
|
|
||||||
return <div data-testid="portal-content" style={{ display: 'block' }}>{children}</div>
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('AddCustomModel', () => {
|
describe('AddCustomModel', () => {
|
||||||
const mockProvider = {
|
const mockProvider = {
|
||||||
@ -94,7 +78,7 @@ describe('AddCustomModel', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i }))
|
||||||
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
|
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -107,10 +91,10 @@ describe('AddCustomModel', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
// The portal should be "open"
|
// The portal should be "open"
|
||||||
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
|
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
|
||||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@ -125,7 +109,7 @@ describe('AddCustomModel', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
fireEvent.click(screen.getByText('gpt-4'))
|
fireEvent.click(screen.getByText('gpt-4'))
|
||||||
|
|
||||||
expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model)
|
expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model)
|
||||||
@ -140,7 +124,7 @@ describe('AddCustomModel', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
|
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
|
||||||
|
|
||||||
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
|
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
|
||||||
@ -159,7 +143,7 @@ describe('AddCustomModel', () => {
|
|||||||
expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument()
|
expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument()
|
||||||
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
|
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i }))
|
||||||
expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled()
|
expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,16 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
} from '@langgenius/dify-ui/button'
|
} from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@langgenius/dify-ui/tooltip'
|
||||||
import {
|
import {
|
||||||
RiAddCircleFill,
|
RiAddCircleFill,
|
||||||
RiAddLine,
|
RiAddLine,
|
||||||
@ -17,12 +27,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import Tooltip from '@/app/components/base/tooltip'
|
|
||||||
import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
import ModelIcon from '../model-icon'
|
import ModelIcon from '../model-icon'
|
||||||
import { useAuth } from './hooks/use-auth'
|
import { useAuth } from './hooks/use-auth'
|
||||||
@ -67,12 +71,12 @@ const AddCustomModel = ({
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||||
|
const renderTrigger = useCallback((open?: boolean, onClick?: () => void) => {
|
||||||
const renderTrigger = useCallback((open?: boolean) => {
|
const item = (
|
||||||
const Item = (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="small"
|
size="small"
|
||||||
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-text-tertiary',
|
'text-text-tertiary',
|
||||||
open && 'bg-components-button-ghost-bg-hover',
|
open && 'bg-components-button-ghost-bg-hover',
|
||||||
@ -85,38 +89,32 @@ const AddCustomModel = ({
|
|||||||
)
|
)
|
||||||
if (notAllowCustomCredential && !!noModels) {
|
if (notAllowCustomCredential && !!noModels) {
|
||||||
return (
|
return (
|
||||||
<Tooltip asChild popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}>
|
<Tooltip>
|
||||||
{Item}
|
<TooltipTrigger render={item} />
|
||||||
|
<TooltipContent>{t('auth.credentialUnavailable', { ns: 'plugin' })}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return Item
|
return item
|
||||||
}, [t, notAllowCustomCredential, noModels])
|
}, [t, notAllowCustomCredential, noModels])
|
||||||
|
|
||||||
|
if (noModels) {
|
||||||
|
return renderTrigger(false, notAllowCustomCredential ? undefined : handleOpenModalForAddNewCustomModel)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => {
|
<PopoverTrigger
|
||||||
if (noModels) {
|
render={<div className="inline-block">{renderTrigger(open)}</div>}
|
||||||
if (notAllowCustomCredential)
|
/>
|
||||||
return
|
<PopoverContent
|
||||||
handleOpenModalForAddNewCustomModel()
|
placement="bottom-end"
|
||||||
return
|
sideOffset={4}
|
||||||
}
|
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||||
|
|
||||||
setOpen(prev => !prev)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{renderTrigger(open)}
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent className="z-1002">
|
|
||||||
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||||
<div className="max-h-[304px] overflow-y-auto p-1">
|
<div className="max-h-[304px] overflow-y-auto p-1">
|
||||||
{
|
{
|
||||||
@ -125,8 +123,8 @@ const AddCustomModel = ({
|
|||||||
key={model.model}
|
key={model.model}
|
||||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover"
|
className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleOpenModalForAddCustomModelToModelList(undefined, model)
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
handleOpenModalForAddCustomModelToModelList(undefined, model)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModelIcon
|
<ModelIcon
|
||||||
@ -150,8 +148,8 @@ const AddCustomModel = ({
|
|||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 system-xs-medium text-text-accent-light-mode-only"
|
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 system-xs-medium text-text-accent-light-mode-only"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleOpenModalForAddNewCustomModel()
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
handleOpenModalForAddNewCustomModel()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiAddLine className="mr-1 h-4 w-4" />
|
<RiAddLine className="mr-1 h-4 w-4" />
|
||||||
@ -160,8 +158,8 @@ const AddCustomModel = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,8 @@ vi.mock('../authorized-item', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
|
||||||
describe('Authorized', () => {
|
describe('Authorized', () => {
|
||||||
const mockProvider: ModelProvider = {
|
const mockProvider: ModelProvider = {
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
import type {
|
||||||
|
OffsetOptions,
|
||||||
|
} from '@floating-ui/react'
|
||||||
|
import type { Placement } from '@langgenius/dify-ui/popover'
|
||||||
|
import type { MouseEvent } from 'react'
|
||||||
import type {
|
import type {
|
||||||
ConfigurationMethodEnum,
|
ConfigurationMethodEnum,
|
||||||
Credential,
|
Credential,
|
||||||
@ -6,9 +11,6 @@ import type {
|
|||||||
ModelModalModeEnum,
|
ModelModalModeEnum,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
} from '../../declarations'
|
} from '../../declarations'
|
||||||
import type {
|
|
||||||
PortalToFollowElemOptions,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogActions,
|
AlertDialogActions,
|
||||||
@ -19,6 +21,11 @@ import {
|
|||||||
} from '@langgenius/dify-ui/alert-dialog'
|
} from '@langgenius/dify-ui/alert-dialog'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiAddLine,
|
RiAddLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
@ -29,11 +36,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useAuth } from '../hooks'
|
import { useAuth } from '../hooks'
|
||||||
import AuthorizedItem from './authorized-item'
|
import AuthorizedItem from './authorized-item'
|
||||||
|
|
||||||
@ -43,7 +45,7 @@ type AuthorizedProps = {
|
|||||||
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
|
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
|
||||||
authParams?: {
|
authParams?: {
|
||||||
isModelCredential?: boolean
|
isModelCredential?: boolean
|
||||||
onUpdate?: (newPayload?: any, formValues?: Record<string, any>) => void
|
onUpdate?: (newPayload?: Record<string, unknown>, formValues?: Record<string, unknown>) => void
|
||||||
onRemove?: (credentialId: string) => void
|
onRemove?: (credentialId: string) => void
|
||||||
mode?: ModelModalModeEnum
|
mode?: ModelModalModeEnum
|
||||||
}
|
}
|
||||||
@ -57,8 +59,8 @@ type AuthorizedProps = {
|
|||||||
renderTrigger: (open?: boolean) => React.ReactNode
|
renderTrigger: (open?: boolean) => React.ReactNode
|
||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
offset?: PortalToFollowElemOptions['offset']
|
offset?: number | OffsetOptions
|
||||||
placement?: PortalToFollowElemOptions['placement']
|
placement?: Placement
|
||||||
triggerPopupSameWidth?: boolean
|
triggerPopupSameWidth?: boolean
|
||||||
popupClassName?: string
|
popupClassName?: string
|
||||||
showItemSelectedIcon?: boolean
|
showItemSelectedIcon?: boolean
|
||||||
@ -132,9 +134,13 @@ const Authorized = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleEdit = useCallback((credential?: Credential, model?: CustomModel) => {
|
const handleEdit = useCallback((credential?: Credential, model?: CustomModel) => {
|
||||||
handleOpenModal(credential, model)
|
|
||||||
setMergedIsOpen(false)
|
setMergedIsOpen(false)
|
||||||
|
handleOpenModal(credential, model)
|
||||||
}, [handleOpenModal, setMergedIsOpen])
|
}, [handleOpenModal, setMergedIsOpen])
|
||||||
|
const handleDelete = useCallback((credential?: Credential, model?: CustomModel) => {
|
||||||
|
setMergedIsOpen(false)
|
||||||
|
openConfirmDelete(credential, model)
|
||||||
|
}, [openConfirmDelete, setMergedIsOpen])
|
||||||
|
|
||||||
const handleItemClick = useCallback((credential: Credential, model?: CustomModel) => {
|
const handleItemClick = useCallback((credential: Credential, model?: CustomModel) => {
|
||||||
if (disableItemClick)
|
if (disableItemClick)
|
||||||
@ -148,30 +154,37 @@ const Authorized = ({
|
|||||||
setMergedIsOpen(false)
|
setMergedIsOpen(false)
|
||||||
}, [handleActiveCredential, onItemClick, setMergedIsOpen, disableItemClick])
|
}, [handleActiveCredential, onItemClick, setMergedIsOpen, disableItemClick])
|
||||||
const notAllowCustomCredential = provider.allow_custom_token === false
|
const notAllowCustomCredential = provider.allow_custom_token === false
|
||||||
|
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
|
||||||
|
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
|
||||||
|
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
|
||||||
|
const popupProps = triggerPopupSameWidth
|
||||||
|
? { style: { width: 'var(--anchor-width, auto)' } }
|
||||||
|
: undefined
|
||||||
|
const handleTriggerClick = useCallback((event: MouseEvent<HTMLElement>) => {
|
||||||
|
if (!triggerOnlyOpenModal)
|
||||||
|
return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
handleOpenModal()
|
||||||
|
}, [handleOpenModal, triggerOnlyOpenModal])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={mergedIsOpen}
|
open={mergedIsOpen}
|
||||||
onOpenChange={setMergedIsOpen}
|
onOpenChange={setMergedIsOpen}
|
||||||
placement={placement}
|
|
||||||
offset={offset}
|
|
||||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
onClick={() => {
|
render={<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>{renderTrigger(mergedIsOpen)}</div>}
|
||||||
if (triggerOnlyOpenModal) {
|
onClick={handleTriggerClick}
|
||||||
handleOpenModal()
|
/>
|
||||||
return
|
<PopoverContent
|
||||||
}
|
placement={placement}
|
||||||
|
sideOffset={sideOffset}
|
||||||
setMergedIsOpen(!mergedIsOpen)
|
alignOffset={alignOffset}
|
||||||
}}
|
popupProps={popupProps}
|
||||||
asChild
|
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||||
>
|
>
|
||||||
{renderTrigger(mergedIsOpen)}
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent className="z-1002">
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
|
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
|
||||||
popupClassName,
|
popupClassName,
|
||||||
@ -186,15 +199,15 @@ const Authorized = ({
|
|||||||
}
|
}
|
||||||
<div className="max-h-[304px] overflow-y-auto">
|
<div className="max-h-[304px] overflow-y-auto">
|
||||||
{
|
{
|
||||||
items.map((item, index) => (
|
items.map(item => (
|
||||||
<Fragment key={index}>
|
<Fragment key={item.model?.model ?? item.title ?? item.credentials.map(credential => credential.credential_id).join('-')}>
|
||||||
<AuthorizedItem
|
<AuthorizedItem
|
||||||
provider={provider}
|
provider={provider}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
model={item.model}
|
model={item.model}
|
||||||
credentials={item.credentials}
|
credentials={item.credentials}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onDelete={openConfirmDelete}
|
onDelete={handleDelete}
|
||||||
disableDeleteButShowAction={disableDeleteButShowAction}
|
disableDeleteButShowAction={disableDeleteButShowAction}
|
||||||
disableDeleteTip={disableDeleteTip}
|
disableDeleteTip={disableDeleteTip}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
@ -204,7 +217,7 @@ const Authorized = ({
|
|||||||
showModelTitle={showModelTitle}
|
showModelTitle={showModelTitle}
|
||||||
/>
|
/>
|
||||||
{
|
{
|
||||||
index !== items.length - 1 && (
|
item !== items[items.length - 1] && (
|
||||||
<div className="h-px bg-divider-subtle"></div>
|
<div className="h-px bg-divider-subtle"></div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -245,8 +258,8 @@ const Authorized = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirmDelete()}>
|
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirmDelete()}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||||
|
|||||||
@ -62,31 +62,38 @@ vi.mock('@/app/components/plugins/hooks', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock portal-to-follow-elem with shared open state
|
// Mock popover with shared open state
|
||||||
let mockPortalOpenState = false
|
let mockPortalOpenState = false
|
||||||
|
let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||||
PortalToFollowElem: ({ children, open }: {
|
Popover: ({ children, open, onOpenChange }: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
open: boolean
|
open: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
}) => {
|
}) => {
|
||||||
mockPortalOpenState = open
|
mockPortalOpenState = open
|
||||||
|
mockPopoverOnOpenChange = onOpenChange
|
||||||
return (
|
return (
|
||||||
<div data-testid="portal-elem" data-open={open}>
|
<div data-testid="portal-elem" data-open={open}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
PortalToFollowElemTrigger: ({ children, onClick, className }: {
|
PopoverTrigger: ({ children, render, className }: {
|
||||||
children: React.ReactNode
|
children?: React.ReactNode
|
||||||
onClick: () => void
|
render?: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
<div
|
||||||
{children}
|
data-testid="portal-trigger"
|
||||||
|
onClick={() => mockPopoverOnOpenChange?.(!mockPortalOpenState)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{render ?? children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
PortalToFollowElemContent: ({ children, className }: {
|
PopoverContent: ({ children, className }: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@ -1,10 +1,19 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import TagsFilter from '../tags-filter'
|
import TagsFilter from '../tags-filter'
|
||||||
|
|
||||||
|
const { mockTranslate } = vi.hoisted(() => ({
|
||||||
|
mockTranslate: vi.fn((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('#i18n', () => ({
|
vi.mock('#i18n', () => ({
|
||||||
useTranslation: () => ({
|
useTranslation: () => ({
|
||||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
t: mockTranslate,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -46,20 +55,7 @@ vi.mock('@/app/components/base/input', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
const _React = await import('react')
|
|
||||||
return {
|
|
||||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
||||||
PortalToFollowElemTrigger: ({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick: () => void
|
|
||||||
}) => <button data-testid="portal-trigger" onClick={onClick}>{children}</button>,
|
|
||||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('../trigger/marketplace', () => ({
|
vi.mock('../trigger/marketplace', () => ({
|
||||||
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
|
default: ({ selectedTagsLength }: { selectedTagsLength: number }) => (
|
||||||
@ -80,8 +76,16 @@ vi.mock('../trigger/tool-selector', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
describe('TagsFilter', () => {
|
describe('TagsFilter', () => {
|
||||||
|
const ensurePopoverOpen = () => {
|
||||||
|
if (!screen.queryByTestId('popover-content'))
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
|
return screen.getByTestId('popover-content')
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockTranslate.mockImplementation((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders marketplace trigger when used in marketplace', () => {
|
it('renders marketplace trigger when used in marketplace', () => {
|
||||||
@ -100,6 +104,7 @@ describe('TagsFilter', () => {
|
|||||||
|
|
||||||
it('filters tag options by search text', () => {
|
it('filters tag options by search text', () => {
|
||||||
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
expect(screen.getByText('Agent')).toBeInTheDocument()
|
expect(screen.getByText('Agent')).toBeInTheDocument()
|
||||||
expect(screen.getByText('RAG')).toBeInTheDocument()
|
expect(screen.getByText('RAG')).toBeInTheDocument()
|
||||||
@ -116,11 +121,20 @@ describe('TagsFilter', () => {
|
|||||||
const onTagsChange = vi.fn()
|
const onTagsChange = vi.fn()
|
||||||
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
const { rerender } = render(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Agent'))
|
fireEvent.click(within(ensurePopoverOpen()).getByText('Agent'))
|
||||||
expect(onTagsChange).toHaveBeenCalledWith([])
|
expect(onTagsChange).toHaveBeenCalledWith([])
|
||||||
|
|
||||||
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
||||||
fireEvent.click(screen.getByText('RAG'))
|
fireEvent.click(within(ensurePopoverOpen()).getByText('RAG'))
|
||||||
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
|
expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('falls back to an empty placeholder when translation is missing', () => {
|
||||||
|
mockTranslate.mockImplementation(() => undefined as unknown as string)
|
||||||
|
|
||||||
|
render(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useTranslation } from '#i18n'
|
import { useTranslation } from '#i18n'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Checkbox from '@/app/components/base/checkbox'
|
import Checkbox from '@/app/components/base/checkbox'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useTags } from '@/app/components/plugins/hooks'
|
import { useTags } from '@/app/components/plugins/hooks'
|
||||||
import MarketplaceTrigger from './trigger/marketplace'
|
import MarketplaceTrigger from './trigger/marketplace'
|
||||||
import ToolSelectorTrigger from './trigger/tool-selector'
|
import ToolSelectorTrigger from './trigger/tool-selector'
|
||||||
@ -37,43 +37,45 @@ const TagsFilter = ({
|
|||||||
const selectedTagsLength = tags.length
|
const selectedTagsLength = tags.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement="bottom-start"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: -6,
|
|
||||||
}}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
className="shrink-0"
|
nativeButton={false}
|
||||||
onClick={() => setOpen(v => !v)}
|
render={(
|
||||||
|
<div className="shrink-0">
|
||||||
|
{
|
||||||
|
usedInMarketplace && (
|
||||||
|
<MarketplaceTrigger
|
||||||
|
selectedTagsLength={selectedTagsLength}
|
||||||
|
open={open}
|
||||||
|
tags={tags}
|
||||||
|
tagsMap={tagsMap}
|
||||||
|
onTagsChange={onTagsChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!usedInMarketplace && (
|
||||||
|
<ToolSelectorTrigger
|
||||||
|
selectedTagsLength={selectedTagsLength}
|
||||||
|
open={open}
|
||||||
|
tags={tags}
|
||||||
|
tagsMap={tagsMap}
|
||||||
|
onTagsChange={onTagsChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-start"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={-6}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
>
|
>
|
||||||
{
|
|
||||||
usedInMarketplace && (
|
|
||||||
<MarketplaceTrigger
|
|
||||||
selectedTagsLength={selectedTagsLength}
|
|
||||||
open={open}
|
|
||||||
tags={tags}
|
|
||||||
tagsMap={tagsMap}
|
|
||||||
onTagsChange={onTagsChange}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
!usedInMarketplace && (
|
|
||||||
<ToolSelectorTrigger
|
|
||||||
selectedTagsLength={selectedTagsLength}
|
|
||||||
open={open}
|
|
||||||
tags={tags}
|
|
||||||
tagsMap={tagsMap}
|
|
||||||
onTagsChange={onTagsChange}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent className="z-1000">
|
|
||||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||||
<div className="p-2 pb-1">
|
<div className="p-2 pb-1">
|
||||||
<Input
|
<Input
|
||||||
@ -103,8 +105,8 @@ const TagsFilter = ({
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,8 @@ vi.mock('@/hooks/use-oauth', () => ({
|
|||||||
openOAuthPopup: vi.fn(),
|
openOAuthPopup: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
|
||||||
// Mock service/use-triggers
|
// Mock service/use-triggers
|
||||||
vi.mock('@/service/use-triggers', () => ({
|
vi.mock('@/service/use-triggers', () => ({
|
||||||
useTriggerPluginDynamicOptions: () => ({
|
useTriggerPluginDynamicOptions: () => ({
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import type { Credential, PluginPayload } from '../types'
|
|
||||||
import type {
|
import type {
|
||||||
PortalToFollowElemOptions,
|
OffsetOptions,
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
} from '@floating-ui/react'
|
||||||
|
import type { Placement } from '@langgenius/dify-ui/popover'
|
||||||
|
import type { Credential, PluginPayload } from '../types'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogActions,
|
AlertDialogActions,
|
||||||
@ -12,6 +13,11 @@ import {
|
|||||||
} from '@langgenius/dify-ui/alert-dialog'
|
} from '@langgenius/dify-ui/alert-dialog'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
@ -23,11 +29,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import Indicator from '@/app/components/header/indicator'
|
import Indicator from '@/app/components/header/indicator'
|
||||||
import Authorize from '../authorize'
|
import Authorize from '../authorize'
|
||||||
import ApiKeyModal from '../authorize/api-key-modal'
|
import ApiKeyModal from '../authorize/api-key-modal'
|
||||||
@ -48,8 +49,8 @@ type AuthorizedProps = {
|
|||||||
renderTrigger?: (open?: boolean) => React.ReactNode
|
renderTrigger?: (open?: boolean) => React.ReactNode
|
||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
offset?: PortalToFollowElemOptions['offset']
|
offset?: number | OffsetOptions
|
||||||
placement?: PortalToFollowElemOptions['placement']
|
placement?: Placement
|
||||||
triggerPopupSameWidth?: boolean
|
triggerPopupSameWidth?: boolean
|
||||||
popupClassName?: string
|
popupClassName?: string
|
||||||
disableSetDefault?: boolean
|
disableSetDefault?: boolean
|
||||||
@ -96,11 +97,12 @@ const Authorized = ({
|
|||||||
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
|
const [deleteCredentialId, setDeleteCredentialId] = useState<string | null>(null)
|
||||||
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
|
const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload)
|
||||||
const openConfirm = useCallback((credentialId?: string) => {
|
const openConfirm = useCallback((credentialId?: string) => {
|
||||||
|
setMergedIsOpen(false)
|
||||||
if (credentialId)
|
if (credentialId)
|
||||||
pendingOperationCredentialId.current = credentialId
|
pendingOperationCredentialId.current = credentialId
|
||||||
|
|
||||||
setDeleteCredentialId(pendingOperationCredentialId.current)
|
setDeleteCredentialId(pendingOperationCredentialId.current)
|
||||||
}, [])
|
}, [setMergedIsOpen])
|
||||||
const closeConfirm = useCallback(() => {
|
const closeConfirm = useCallback(() => {
|
||||||
setDeleteCredentialId(null)
|
setDeleteCredentialId(null)
|
||||||
pendingOperationCredentialId.current = null
|
pendingOperationCredentialId.current = null
|
||||||
@ -130,11 +132,12 @@ const Authorized = ({
|
|||||||
handleSetDoingAction(false)
|
handleSetDoingAction(false)
|
||||||
}
|
}
|
||||||
}, [deletePluginCredential, onUpdate, t, handleSetDoingAction])
|
}, [deletePluginCredential, onUpdate, t, handleSetDoingAction])
|
||||||
const [editValues, setEditValues] = useState<Record<string, any> | null>(null)
|
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null)
|
||||||
const handleEdit = useCallback((id: string, values: Record<string, any>) => {
|
const handleEdit = useCallback((id: string, values: Record<string, unknown>) => {
|
||||||
|
setMergedIsOpen(false)
|
||||||
pendingOperationCredentialId.current = id
|
pendingOperationCredentialId.current = id
|
||||||
setEditValues(values)
|
setEditValues(values)
|
||||||
}, [])
|
}, [setMergedIsOpen])
|
||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(() => {
|
||||||
setDeleteCredentialId(pendingOperationCredentialId.current)
|
setDeleteCredentialId(pendingOperationCredentialId.current)
|
||||||
}, [])
|
}, [])
|
||||||
@ -171,49 +174,59 @@ const Authorized = ({
|
|||||||
}, [updatePluginCredential, t, handleSetDoingAction, onUpdate])
|
}, [updatePluginCredential, t, handleSetDoingAction, onUpdate])
|
||||||
const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use)
|
const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use)
|
||||||
const unavailableCredential = credentials.find(credential => credential.not_allowed_to_use && credential.is_default)
|
const unavailableCredential = credentials.find(credential => credential.not_allowed_to_use && credential.is_default)
|
||||||
|
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
|
||||||
|
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
|
||||||
|
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
|
||||||
|
const popupProps = triggerPopupSameWidth
|
||||||
|
? { style: { width: 'var(--anchor-width, auto)' } }
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={mergedIsOpen}
|
open={mergedIsOpen}
|
||||||
onOpenChange={setMergedIsOpen}
|
onOpenChange={setMergedIsOpen}
|
||||||
placement={placement}
|
|
||||||
offset={offset}
|
|
||||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
onClick={() => setMergedIsOpen(!mergedIsOpen)}
|
render={(
|
||||||
asChild
|
<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>
|
||||||
>
|
{
|
||||||
{
|
renderTrigger
|
||||||
renderTrigger
|
? renderTrigger(mergedIsOpen)
|
||||||
? renderTrigger(mergedIsOpen)
|
: (
|
||||||
: (
|
<Button
|
||||||
<Button
|
className={cn(
|
||||||
className={cn(
|
'w-full',
|
||||||
'w-full',
|
mergedIsOpen && 'bg-components-button-secondary-bg-hover',
|
||||||
isOpen && 'bg-components-button-secondary-bg-hover',
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} />
|
||||||
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} />
|
{credentials.length}
|
||||||
{credentials.length}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
credentials.length > 1
|
credentials.length > 1
|
||||||
? t('auth.authorizations', { ns: 'plugin' })
|
? t('auth.authorizations', { ns: 'plugin' })
|
||||||
: t('auth.authorization', { ns: 'plugin' })
|
: t('auth.authorization', { ns: 'plugin' })
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!!unavailableCredentials.length && (
|
!!unavailableCredentials.length && (
|
||||||
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
|
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
|
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</PortalToFollowElemTrigger>
|
</div>
|
||||||
<PortalToFollowElemContent className="z-100">
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement={placement}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
popupProps={popupProps}
|
||||||
|
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||||
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
|
'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
|
||||||
popupClassName,
|
popupClassName,
|
||||||
@ -323,8 +336,8 @@ const Authorized = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirm()}>
|
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirm()}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||||
|
|||||||
@ -46,8 +46,8 @@ vi.mock('@/app/components/base/input', () => ({
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||||
PortalToFollowElem: ({
|
Popover: ({
|
||||||
children,
|
children,
|
||||||
open,
|
open,
|
||||||
}: {
|
}: {
|
||||||
@ -58,18 +58,20 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
PortalToFollowElemTrigger: ({
|
PopoverTrigger: ({
|
||||||
children,
|
children,
|
||||||
|
render,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
render?: ReactNode
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}) => (
|
}) => (
|
||||||
<button data-testid="picker-trigger" onClick={onClick}>
|
<button data-testid="picker-trigger" onClick={onClick}>
|
||||||
{children}
|
{render ?? children}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
|
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||||
<div data-testid="portal-content">{children}</div>
|
<div data-testid="portal-content">{children}</div>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -76,7 +76,7 @@ afterAll(() => {
|
|||||||
|
|
||||||
// Mock portal components for controlled positioning in tests
|
// Mock portal components for controlled positioning in tests
|
||||||
// Use React context to properly scope open state per portal instance (for nested portals)
|
// Use React context to properly scope open state per portal instance (for nested portals)
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
vi.mock('@langgenius/dify-ui/popover', () => {
|
||||||
// Context reference shared across mock components
|
// Context reference shared across mock components
|
||||||
let sharedContext: React.Context<boolean> | null = null
|
let sharedContext: React.Context<boolean> | null = null
|
||||||
|
|
||||||
@ -90,7 +90,7 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
PortalToFollowElem: ({
|
Popover: ({
|
||||||
children,
|
children,
|
||||||
open,
|
open,
|
||||||
}: {
|
}: {
|
||||||
@ -104,20 +104,22 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
|||||||
React.createElement('div', { 'data-testid': 'portal-to-follow-elem', 'data-open': open }, children),
|
React.createElement('div', { 'data-testid': 'portal-to-follow-elem', 'data-open': open }, children),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
PortalToFollowElemTrigger: ({
|
PopoverTrigger: ({
|
||||||
children,
|
children,
|
||||||
|
render,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
render?: ReactNode
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
||||||
{children}
|
{render ?? children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => {
|
PopoverContent: ({ children, className }: { children: ReactNode, className?: string }) => {
|
||||||
const Context = getContext()
|
const Context = getContext()
|
||||||
const isOpen = React.useContext(Context)
|
const isOpen = React.useContext(Context)
|
||||||
if (!isOpen)
|
if (!isOpen)
|
||||||
|
|||||||
@ -5,16 +5,16 @@ import type {
|
|||||||
} from '@floating-ui/react'
|
} from '@floating-ui/react'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { App } from '@/types/app'
|
import type { App } from '@/types/app'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import AppIcon from '@/app/components/base/app-icon'
|
import AppIcon from '@/app/components/base/app-icon'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -154,26 +154,33 @@ const AppPicker: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTriggerClick = () => {
|
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
|
||||||
if (disabled)
|
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
|
||||||
|
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
|
||||||
|
const handleTriggerClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (disabled || isShow)
|
||||||
return
|
return
|
||||||
|
|
||||||
onShowChange(true)
|
onShowChange(true)
|
||||||
}
|
}, [disabled, isShow, onShowChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement={placement}
|
|
||||||
offset={offset}
|
|
||||||
open={isShow}
|
open={isShow}
|
||||||
onOpenChange={onShowChange}
|
onOpenChange={onShowChange}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
|
render={<div>{trigger}</div>}
|
||||||
onClick={handleTriggerClick}
|
onClick={handleTriggerClick}
|
||||||
>
|
/>
|
||||||
{trigger}
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
|
|
||||||
<PortalToFollowElemContent className="z-1000">
|
<PopoverContent
|
||||||
|
placement={placement}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||||
|
>
|
||||||
<div className="relative flex max-h-[400px] min-h-20 w-[356px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
<div className="relative flex max-h-[400px] min-h-20 w-[356px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||||
<div className="p-2 pb-1">
|
<div className="p-2 pb-1">
|
||||||
<Input
|
<Input
|
||||||
@ -219,8 +226,8 @@ const AppPicker: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,14 +5,14 @@ import type {
|
|||||||
} from '@floating-ui/react'
|
} from '@floating-ui/react'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { App } from '@/types/app'
|
import type { App } from '@/types/app'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
|
import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel'
|
||||||
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
|
import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker'
|
||||||
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
|
import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger'
|
||||||
@ -94,6 +94,9 @@ const AppSelector: FC<Props> = ({
|
|||||||
}, [currentAppInfo, displayedApps])
|
}, [currentAppInfo, displayedApps])
|
||||||
|
|
||||||
const hasMore = hasNextPage ?? true
|
const hasMore = hasNextPage ?? true
|
||||||
|
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
|
||||||
|
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
|
||||||
|
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
|
||||||
|
|
||||||
const handleLoadMore = useCallback(async () => {
|
const handleLoadMore = useCallback(async () => {
|
||||||
if (isFetchingNextPage || !hasMore)
|
if (isFetchingNextPage || !hasMore)
|
||||||
@ -102,11 +105,13 @@ const AppSelector: FC<Props> = ({
|
|||||||
await fetchNextPage()
|
await fetchNextPage()
|
||||||
}, [fetchNextPage, hasMore, isFetchingNextPage])
|
}, [fetchNextPage, hasMore, isFetchingNextPage])
|
||||||
|
|
||||||
const handleTriggerClick = () => {
|
const handleTriggerClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||||
if (disabled)
|
event.preventDefault()
|
||||||
|
if (disabled || isShow)
|
||||||
return
|
return
|
||||||
|
|
||||||
setIsShow(true)
|
setIsShow(true)
|
||||||
}
|
}, [disabled, isShow])
|
||||||
|
|
||||||
const [isShowChooseApp, setIsShowChooseApp] = useState(false)
|
const [isShowChooseApp, setIsShowChooseApp] = useState(false)
|
||||||
const handleSelectApp = (app: App) => {
|
const handleSelectApp = (app: App) => {
|
||||||
@ -143,22 +148,27 @@ const AppSelector: FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement={placement}
|
|
||||||
offset={offset}
|
|
||||||
open={isShow}
|
open={isShow}
|
||||||
onOpenChange={setIsShow}
|
onOpenChange={setIsShow}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
className="w-full"
|
render={(
|
||||||
|
<div className="w-full">
|
||||||
|
<AppTrigger
|
||||||
|
open={isShow}
|
||||||
|
appDetail={currentAppInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
onClick={handleTriggerClick}
|
onClick={handleTriggerClick}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement={placement}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||||
>
|
>
|
||||||
<AppTrigger
|
|
||||||
open={isShow}
|
|
||||||
appDetail={currentAppInfo}
|
|
||||||
/>
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent className="z-1000">
|
|
||||||
<div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
<div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||||
<div className="flex flex-col gap-1 px-4 py-3">
|
<div className="flex flex-col gap-1 px-4 py-3">
|
||||||
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">{t('appSelector.label', { ns: 'app' })}</div>
|
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">{t('appSelector.label', { ns: 'app' })}</div>
|
||||||
@ -193,8 +203,8 @@ const AppSelector: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -153,8 +153,8 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Portal components need mocking for controlled positioning in tests
|
// Portal components need mocking for controlled positioning in tests
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||||
PortalToFollowElem: ({
|
Popover: ({
|
||||||
children,
|
children,
|
||||||
open,
|
open,
|
||||||
}: {
|
}: {
|
||||||
@ -165,18 +165,20 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
PortalToFollowElemTrigger: ({
|
PopoverTrigger: ({
|
||||||
children,
|
children,
|
||||||
|
render,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
render?: ReactNode
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}) => (
|
}) => (
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>
|
<div data-testid="portal-trigger" onClick={onClick}>
|
||||||
{children}
|
{render ?? children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
|
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||||
<div data-testid="portal-content">{children}</div>
|
<div data-testid="portal-content">{children}</div>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -8,13 +8,13 @@ import type { Node } from 'reactflow'
|
|||||||
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
|
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { CollectionType } from '@/app/components/tools/types'
|
import { CollectionType } from '@/app/components/tools/types'
|
||||||
import Link from '@/next/link'
|
import Link from '@/next/link'
|
||||||
import {
|
import {
|
||||||
@ -102,15 +102,21 @@ const ToolSelector: FC<Props> = ({
|
|||||||
getSettingsValue,
|
getSettingsValue,
|
||||||
} = state
|
} = state
|
||||||
|
|
||||||
const handleTriggerClick = () => {
|
const handleTriggerClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
if (disabled)
|
if (disabled)
|
||||||
return
|
return
|
||||||
|
if (!currentProvider || !currentTool)
|
||||||
|
return
|
||||||
setIsShow(true)
|
setIsShow(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine portal open state based on controlled vs uncontrolled mode
|
// Determine portal open state based on controlled vs uncontrolled mode
|
||||||
const portalOpen = trigger ? controlledState : isShow
|
const portalOpen = trigger ? controlledState : isShow
|
||||||
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
|
const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow
|
||||||
|
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
|
||||||
|
const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0
|
||||||
|
const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0
|
||||||
|
|
||||||
// Build error tooltip content
|
// Build error tooltip content
|
||||||
const renderErrorTip = () => (
|
const renderErrorTip = () => (
|
||||||
@ -134,57 +140,58 @@ const ToolSelector: FC<Props> = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement={placement}
|
|
||||||
offset={offset}
|
|
||||||
open={portalOpen}
|
open={portalOpen}
|
||||||
onOpenChange={onPortalOpenChange}
|
onOpenChange={onPortalOpenChange}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
className="w-full"
|
render={(
|
||||||
onClick={() => {
|
<div className="w-full">
|
||||||
if (!currentProvider || !currentTool)
|
{trigger}
|
||||||
return
|
|
||||||
handleTriggerClick()
|
{/* Default trigger - no value */}
|
||||||
}}
|
{!trigger && !value?.provider_name && (
|
||||||
|
<ToolTrigger
|
||||||
|
isConfigure
|
||||||
|
open={isShow}
|
||||||
|
value={value}
|
||||||
|
provider={currentProvider}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default trigger - with value */}
|
||||||
|
{!trigger && value?.provider_name && (
|
||||||
|
<ToolItem
|
||||||
|
open={isShow}
|
||||||
|
icon={currentProvider?.icon || manifestIcon}
|
||||||
|
isMCPTool={currentProvider?.type === CollectionType.mcp}
|
||||||
|
providerName={value.provider_name}
|
||||||
|
providerShowName={value.provider_show_name}
|
||||||
|
toolLabel={value.tool_label || value.tool_name}
|
||||||
|
showSwitch={supportEnableSwitch}
|
||||||
|
switchValue={value.enabled}
|
||||||
|
onSwitchChange={handleEnabledChange}
|
||||||
|
onDelete={onDelete}
|
||||||
|
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
|
||||||
|
uninstalled={!currentProvider && inMarketPlace}
|
||||||
|
versionMismatch={currentProvider && inMarketPlace && !currentTool}
|
||||||
|
installInfo={manifest?.latest_package_identifier}
|
||||||
|
onInstall={handleInstall}
|
||||||
|
isError={(!currentProvider || !currentTool) && !inMarketPlace}
|
||||||
|
errorTip={renderErrorTip()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
onClick={handleTriggerClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
placement={placement}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||||
>
|
>
|
||||||
{trigger}
|
|
||||||
|
|
||||||
{/* Default trigger - no value */}
|
|
||||||
{!trigger && !value?.provider_name && (
|
|
||||||
<ToolTrigger
|
|
||||||
isConfigure
|
|
||||||
open={isShow}
|
|
||||||
value={value}
|
|
||||||
provider={currentProvider}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Default trigger - with value */}
|
|
||||||
{!trigger && value?.provider_name && (
|
|
||||||
<ToolItem
|
|
||||||
open={isShow}
|
|
||||||
icon={currentProvider?.icon || manifestIcon}
|
|
||||||
isMCPTool={currentProvider?.type === CollectionType.mcp}
|
|
||||||
providerName={value.provider_name}
|
|
||||||
providerShowName={value.provider_show_name}
|
|
||||||
toolLabel={value.tool_label || value.tool_name}
|
|
||||||
showSwitch={supportEnableSwitch}
|
|
||||||
switchValue={value.enabled}
|
|
||||||
onSwitchChange={handleEnabledChange}
|
|
||||||
onDelete={onDelete}
|
|
||||||
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
|
|
||||||
uninstalled={!currentProvider && inMarketPlace}
|
|
||||||
versionMismatch={currentProvider && inMarketPlace && !currentTool}
|
|
||||||
installInfo={manifest?.latest_package_identifier}
|
|
||||||
onInstall={handleInstall}
|
|
||||||
isError={(!currentProvider || !currentTool) && !inMarketPlace}
|
|
||||||
errorTip={renderErrorTip()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
|
|
||||||
<PortalToFollowElemContent className="z-10">
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
|
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
|
||||||
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
|
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
|
||||||
@ -239,8 +246,8 @@ const ToolSelector: FC<Props> = ({
|
|||||||
onParamsFormChange={handleParamsFormChange}
|
onParamsFormChange={handleParamsFormChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,17 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
|
||||||
<div data-testid="portal" data-open={open}>{children}</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div data-testid="portal-content">{children}</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@langgenius/dify-ui/cn', () => ({
|
vi.mock('@langgenius/dify-ui/cn', () => ({
|
||||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||||
@ -67,7 +57,7 @@ describe('CategoriesFilter', () => {
|
|||||||
const mockOnChange = vi.fn()
|
const mockOnChange = vi.fn()
|
||||||
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||||
|
|
||||||
const trigger = screen.getByTestId('portal-trigger')
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
const clearSvg = trigger.querySelector('svg')
|
const clearSvg = trigger.querySelector('svg')
|
||||||
fireEvent.click(clearSvg!)
|
fireEvent.click(clearSvg!)
|
||||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||||
@ -75,6 +65,7 @@ describe('CategoriesFilter', () => {
|
|||||||
|
|
||||||
it('should render category options in dropdown', () => {
|
it('should render category options in dropdown', () => {
|
||||||
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
expect(screen.getByText('Tool'))!.toBeInTheDocument()
|
expect(screen.getByText('Tool'))!.toBeInTheDocument()
|
||||||
expect(screen.getByText('Model'))!.toBeInTheDocument()
|
expect(screen.getByText('Model'))!.toBeInTheDocument()
|
||||||
@ -85,6 +76,7 @@ describe('CategoriesFilter', () => {
|
|||||||
const mockOnChange = vi.fn()
|
const mockOnChange = vi.fn()
|
||||||
render(<CategoriesFilter value={[]} onChange={mockOnChange} />)
|
render(<CategoriesFilter value={[]} onChange={mockOnChange} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
fireEvent.click(screen.getByText('Tool'))
|
fireEvent.click(screen.getByText('Tool'))
|
||||||
expect(mockOnChange).toHaveBeenCalledWith(['tool'])
|
expect(mockOnChange).toHaveBeenCalledWith(['tool'])
|
||||||
})
|
})
|
||||||
@ -93,8 +85,20 @@ describe('CategoriesFilter', () => {
|
|||||||
const mockOnChange = vi.fn()
|
const mockOnChange = vi.fn()
|
||||||
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
render(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
const toolElements = screen.getAllByText('Tool')
|
const toolElements = screen.getAllByText('Tool')
|
||||||
fireEvent.click(toolElements[toolElements.length - 1]!)
|
fireEvent.click(toolElements[toolElements.length - 1]!)
|
||||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should filter categories by search text', () => {
|
||||||
|
render(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { target: { value: 'mod' } })
|
||||||
|
|
||||||
|
expect(screen.queryByText('Tool')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Model')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Extension')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Category, Tag } from '../constant'
|
import type { Category, Tag } from '../constant'
|
||||||
import type { FilterState } from '../index'
|
import type { FilterState } from '../index'
|
||||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
// ==================== Imports (after mocks) ====================
|
// ==================== Imports (after mocks) ====================
|
||||||
@ -68,19 +69,47 @@ vi.mock('../../../hooks', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Track portal open state for testing
|
type MockPopoverContextValue = {
|
||||||
let mockPortalOpenState = false
|
open: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
const MockPopoverContext = createContext<MockPopoverContextValue>({
|
||||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
|
open: false,
|
||||||
mockPortalOpenState = open
|
})
|
||||||
return <div data-testid="portal-container" data-open={open}>{children}</div>
|
|
||||||
},
|
vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
Popover: ({ children, open, onOpenChange }: {
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
children: React.ReactNode
|
||||||
|
open: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) => (
|
||||||
|
<MockPopoverContext.Provider value={{ open, onOpenChange }}>
|
||||||
|
<div data-testid="portal-container" data-open={open}>{children}</div>
|
||||||
|
</MockPopoverContext.Provider>
|
||||||
),
|
),
|
||||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
PopoverTrigger: ({ children, render, className }: {
|
||||||
if (!mockPortalOpenState)
|
children?: React.ReactNode
|
||||||
|
render?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) => {
|
||||||
|
const { open, onOpenChange } = useContext(MockPopoverContext)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="portal-trigger"
|
||||||
|
onClick={() => onOpenChange?.(!open)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{render ?? children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
PopoverContent: ({ children, className }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) => {
|
||||||
|
const { open } = useContext(MockPopoverContext)
|
||||||
|
if (!open)
|
||||||
return null
|
return null
|
||||||
return <div data-testid="portal-content" className={className}>{children}</div>
|
return <div data-testid="portal-content" className={className}>{children}</div>
|
||||||
},
|
},
|
||||||
@ -457,7 +486,6 @@ describe('SearchBox Component', () => {
|
|||||||
describe('CategoriesFilter Component', () => {
|
describe('CategoriesFilter Component', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockPortalOpenState = false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
@ -694,7 +722,6 @@ describe('CategoriesFilter Component', () => {
|
|||||||
describe('TagFilter Component', () => {
|
describe('TagFilter Component', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockPortalOpenState = false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
@ -857,7 +884,6 @@ describe('FilterManagement Component', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockInitFilters = createFilterState()
|
mockInitFilters = createFilterState()
|
||||||
mockPortalOpenState = false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import TagFilter from '../tag-filter'
|
import TagFilter from '../tag-filter'
|
||||||
|
|
||||||
let portalOpen = false
|
|
||||||
|
|
||||||
vi.mock('../../../hooks', () => ({
|
vi.mock('../../../hooks', () => ({
|
||||||
useTags: () => ({
|
useTags: () => ({
|
||||||
tags: [
|
tags: [
|
||||||
@ -19,35 +17,17 @@ vi.mock('../../../hooks', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
PortalToFollowElem: ({
|
|
||||||
children,
|
|
||||||
open,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
open: boolean
|
|
||||||
}) => {
|
|
||||||
portalOpen = open
|
|
||||||
return <div>{children}</div>
|
|
||||||
},
|
|
||||||
PortalToFollowElemTrigger: ({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick: () => void
|
|
||||||
}) => <button data-testid="trigger" onClick={onClick}>{children}</button>,
|
|
||||||
PortalToFollowElemContent: ({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('TagFilter', () => {
|
describe('TagFilter', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
portalOpen = false
|
})
|
||||||
|
|
||||||
|
it('renders the all tags placeholder when nothing is selected', () => {
|
||||||
|
render(<TagFilter value={[]} onChange={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders selected tag labels and the overflow counter', () => {
|
it('renders selected tag labels and the overflow counter', () => {
|
||||||
@ -61,8 +41,8 @@ describe('TagFilter', () => {
|
|||||||
const onChange = vi.fn()
|
const onChange = vi.fn()
|
||||||
render(<TagFilter value={['agent']} onChange={onChange} />)
|
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('trigger'))
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
const portal = screen.getByTestId('portal-content')
|
const portal = screen.getByTestId('popover-content')
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } })
|
fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } })
|
||||||
|
|
||||||
@ -73,4 +53,24 @@ describe('TagFilter', () => {
|
|||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith(['agent', 'rag'])
|
expect(onChange).toHaveBeenCalledWith(['agent', 'rag'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clears all selected tags when the clear icon is clicked', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('popover-trigger')
|
||||||
|
fireEvent.click(trigger.querySelector('svg')!)
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes a selected tag when clicking the same option again', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<TagFilter value={['agent']} onChange={onChange} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
fireEvent.click(within(screen.getByTestId('popover-content')).getByText('Agent'))
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
RiCloseCircleFill,
|
RiCloseCircleFill,
|
||||||
@ -9,11 +14,6 @@ import { useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Checkbox from '@/app/components/base/checkbox'
|
import Checkbox from '@/app/components/base/checkbox'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useCategories } from '../../hooks'
|
import { useCategories } from '../../hooks'
|
||||||
|
|
||||||
type CategoriesFilterProps = {
|
type CategoriesFilterProps = {
|
||||||
@ -38,61 +38,64 @@ const CategoriesFilter = ({
|
|||||||
const selectedTagsLength = value.length
|
const selectedTagsLength = value.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement="bottom-start"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
}}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
<PopoverTrigger
|
||||||
<div className={cn(
|
nativeButton={false}
|
||||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
|
render={(
|
||||||
selectedTagsLength && 'text-text-secondary',
|
|
||||||
open && 'bg-state-base-hover',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex items-center p-1 system-sm-medium',
|
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
|
||||||
|
selectedTagsLength && 'text-text-secondary',
|
||||||
|
open && 'bg-state-base-hover',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center p-1 system-sm-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
!selectedTagsLength && t('allCategories', { ns: 'plugin' })
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',')
|
||||||
|
}
|
||||||
|
{
|
||||||
|
selectedTagsLength > 2 && (
|
||||||
|
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||||
|
+
|
||||||
|
{selectedTagsLength - 2}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
{
|
{
|
||||||
!selectedTagsLength && t('allCategories', { ns: 'plugin' })
|
!!selectedTagsLength && (
|
||||||
|
<RiCloseCircleFill
|
||||||
|
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||||
|
onClick={
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',')
|
!selectedTagsLength && (
|
||||||
}
|
<RiArrowDownSLine className="h-4 w-4" />
|
||||||
{
|
|
||||||
selectedTagsLength > 2 && (
|
|
||||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
|
||||||
+
|
|
||||||
{selectedTagsLength - 2}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{
|
)}
|
||||||
!!selectedTagsLength && (
|
/>
|
||||||
<RiCloseCircleFill
|
<PopoverContent
|
||||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
placement="bottom-start"
|
||||||
onClick={
|
sideOffset={4}
|
||||||
(e) => {
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
e.stopPropagation()
|
>
|
||||||
onChange([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
!selectedTagsLength && (
|
|
||||||
<RiArrowDownSLine className="h-4 w-4" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent className="z-10">
|
|
||||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||||
<div className="p-2 pb-1">
|
<div className="p-2 pb-1">
|
||||||
<Input
|
<Input
|
||||||
@ -122,8 +125,8 @@ const CategoriesFilter = ({
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
RiCloseCircleFill,
|
RiCloseCircleFill,
|
||||||
@ -9,11 +14,6 @@ import { useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Checkbox from '@/app/components/base/checkbox'
|
import Checkbox from '@/app/components/base/checkbox'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useTags } from '../../hooks'
|
import { useTags } from '../../hooks'
|
||||||
|
|
||||||
type TagsFilterProps = {
|
type TagsFilterProps = {
|
||||||
@ -38,56 +38,62 @@ const TagsFilter = ({
|
|||||||
const selectedTagsLength = value.length
|
const selectedTagsLength = value.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement="bottom-start"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
}}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
<PopoverTrigger
|
||||||
<div className={cn(
|
nativeButton={false}
|
||||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
|
render={(
|
||||||
selectedTagsLength && 'text-text-secondary',
|
|
||||||
open && 'bg-state-base-hover',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex items-center p-1 system-sm-medium',
|
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
|
||||||
|
selectedTagsLength && 'text-text-secondary',
|
||||||
|
open && 'bg-state-base-hover',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center p-1 system-sm-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
!selectedTagsLength && t('allTags', { ns: 'pluginTags' })
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',')
|
||||||
|
}
|
||||||
|
{
|
||||||
|
selectedTagsLength > 2 && (
|
||||||
|
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||||
|
+
|
||||||
|
{selectedTagsLength - 2}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
{
|
{
|
||||||
!selectedTagsLength && t('allTags', { ns: 'pluginTags' })
|
!!selectedTagsLength && (
|
||||||
|
<RiCloseCircleFill
|
||||||
|
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange([])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',')
|
!selectedTagsLength && (
|
||||||
}
|
<RiArrowDownSLine className="h-4 w-4" />
|
||||||
{
|
|
||||||
selectedTagsLength > 2 && (
|
|
||||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
|
||||||
+
|
|
||||||
{selectedTagsLength - 2}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{
|
)}
|
||||||
!!selectedTagsLength && (
|
/>
|
||||||
<RiCloseCircleFill
|
<PopoverContent
|
||||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
placement="bottom-start"
|
||||||
onClick={() => onChange([])}
|
sideOffset={4}
|
||||||
/>
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
)
|
>
|
||||||
}
|
|
||||||
{
|
|
||||||
!selectedTagsLength && (
|
|
||||||
<RiArrowDownSLine className="h-4 w-4" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent className="z-10">
|
|
||||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||||
<div className="p-2 pb-1">
|
<div className="p-2 pb-1">
|
||||||
<Input
|
<Input
|
||||||
@ -117,8 +123,8 @@ const TagsFilter = ({
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -192,27 +192,6 @@ vi.mock('ahooks', () => ({
|
|||||||
useKeyPress: vi.fn(),
|
useKeyPress: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
let portalOpenState = false
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
||||||
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: PropsWithChildren<{
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
placement?: string
|
|
||||||
offset?: unknown
|
|
||||||
}>) => {
|
|
||||||
portalOpenState = open
|
|
||||||
return <div data-testid="portal-elem" data-open={open}>{children}</div>
|
|
||||||
},
|
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: PropsWithChildren<{ onClick?: () => void }>) => (
|
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemContent: ({ children }: PropsWithChildren) => {
|
|
||||||
if (!portalOpenState)
|
|
||||||
return null
|
|
||||||
return <div data-testid="portal-content">{children}</div>
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
|
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
|
||||||
default: ({ onConfirm, onCancel }: {
|
default: ({ onConfirm, onCancel }: {
|
||||||
onConfirm: (name: string, icon: unknown, description?: string) => void
|
onConfirm: (name: string, icon: unknown, description?: string) => void
|
||||||
@ -229,7 +208,6 @@ vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
|
|||||||
describe('RagPipelineHeader', () => {
|
describe('RagPipelineHeader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
portalOpenState = false
|
|
||||||
mockStoreState = {
|
mockStoreState = {
|
||||||
pipelineId: 'test-pipeline-id',
|
pipelineId: 'test-pipeline-id',
|
||||||
showDebugAndPreviewPanel: false,
|
showDebugAndPreviewPanel: false,
|
||||||
@ -351,7 +329,6 @@ describe('InputFieldButton', () => {
|
|||||||
describe('Publisher', () => {
|
describe('Publisher', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
portalOpenState = false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
@ -367,9 +344,9 @@ describe('Publisher', () => {
|
|||||||
expect(button)!.toHaveClass('px-2')
|
expect(button)!.toHaveClass('px-2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render portal trigger element', () => {
|
it('should render publish trigger button', () => {
|
||||||
render(<Publisher />)
|
render(<Publisher />)
|
||||||
expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /workflow\.common\.publish/i }))!.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -377,7 +354,7 @@ describe('Publisher', () => {
|
|||||||
it('should call handleSyncWorkflowDraft when opening', () => {
|
it('should call handleSyncWorkflowDraft when opening', () => {
|
||||||
render(<Publisher />)
|
render(<Publisher />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
|
||||||
|
|
||||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||||
})
|
})
|
||||||
@ -385,12 +362,14 @@ describe('Publisher', () => {
|
|||||||
it('should toggle open state when trigger clicked', () => {
|
it('should toggle open state when trigger clicked', () => {
|
||||||
render(<Publisher />)
|
render(<Publisher />)
|
||||||
|
|
||||||
const portal = screen.getByTestId('portal-elem')
|
const trigger = screen.getByRole('button', { name: /workflow\.common\.publish/i })
|
||||||
expect(portal)!.toHaveAttribute('data-open', 'false')
|
expect(trigger)!.toHaveAttribute('aria-expanded', 'false')
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(trigger)
|
||||||
|
|
||||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
|
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
|
||||||
|
expect(trigger)!.toHaveAttribute('aria-expanded', 'true')
|
||||||
|
expect(screen.getByText(/workflow\.common\.publishUpdate/i))!.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -978,7 +957,6 @@ describe('RunMode', () => {
|
|||||||
describe('Integration', () => {
|
describe('Integration', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
portalOpenState = false
|
|
||||||
mockStoreState = {
|
mockStoreState = {
|
||||||
pipelineId: 'test-pipeline-id',
|
pipelineId: 'test-pipeline-id',
|
||||||
showDebugAndPreviewPanel: false,
|
showDebugAndPreviewPanel: false,
|
||||||
|
|||||||
@ -6,6 +6,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import Publisher from '../index'
|
import Publisher from '../index'
|
||||||
import Popup from '../popup'
|
import Popup from '../popup'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||||
|
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||||
|
Button: ({ children, onClick, disabled, variant, className }: Record<string, unknown>) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick as (() => void) | undefined}
|
||||||
|
disabled={disabled as boolean | undefined}
|
||||||
|
data-variant={variant as string | undefined}
|
||||||
|
className={className as string | undefined}
|
||||||
|
>
|
||||||
|
{children as React.ReactNode}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
|
||||||
|
AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||||
|
open
|
||||||
|
? (
|
||||||
|
<div role="alertdialog">
|
||||||
|
{children}
|
||||||
|
<button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
AlertDialogCancelButton: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
|
||||||
|
AlertDialogConfirmButton: ({ children, onClick, disabled }: Record<string, unknown>) => <button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined}>{children as React.ReactNode}</button>,
|
||||||
|
AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
const mockPush = vi.fn()
|
const mockPush = vi.fn()
|
||||||
vi.mock('@/next/navigation', () => ({
|
vi.mock('@/next/navigation', () => ({
|
||||||
useParams: () => ({ datasetId: 'test-dataset-id' }),
|
useParams: () => ({ datasetId: 'test-dataset-id' }),
|
||||||
@ -60,7 +94,8 @@ vi.mock('@/context/dataset-detail', () => ({
|
|||||||
|
|
||||||
const mockSetShowPricingModal = vi.fn()
|
const mockSetShowPricingModal = vi.fn()
|
||||||
vi.mock('@/context/modal-context', () => ({
|
vi.mock('@/context/modal-context', () => ({
|
||||||
useModalContextSelector: () => mockSetShowPricingModal,
|
useModalContextSelector: <T,>(selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T): T =>
|
||||||
|
selector({ setShowPricingModal: mockSetShowPricingModal }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockIsAllowPublishAsCustomKnowledgePipelineTemplate = vi.fn(() => true)
|
const mockIsAllowPublishAsCustomKnowledgePipelineTemplate = vi.fn(() => true)
|
||||||
@ -200,8 +235,7 @@ describe('publisher', () => {
|
|||||||
it('should render portal element in closed state by default', () => {
|
it('should render portal element in closed state by default', () => {
|
||||||
renderWithQueryClient(<Publisher />)
|
renderWithQueryClient(<Publisher />)
|
||||||
|
|
||||||
const trigger = screen.getByText('workflow.common.publish').closest('[data-state]')
|
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false')
|
||||||
expect(trigger).toHaveAttribute('data-state', 'closed')
|
|
||||||
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
|
expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -277,6 +311,25 @@ describe('publisher', () => {
|
|||||||
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
|
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should close the outer popover before opening publish-as follow-up flow', async () => {
|
||||||
|
mockPublishedAt.mockReturnValue(1700000000)
|
||||||
|
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false)
|
||||||
|
renderWithQueryClient(<Publisher />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('pipeline.common.publishAs')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -688,7 +741,7 @@ describe('publisher', () => {
|
|||||||
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
fireEvent.click(screen.getByTestId('alert-dialog-close'))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||||
|
|||||||
@ -3,6 +3,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
import Popup from '../popup'
|
import Popup from '../popup'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
|
||||||
|
AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||||
|
open
|
||||||
|
? (
|
||||||
|
<div role="alertdialog">
|
||||||
|
{children}
|
||||||
|
<button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
AlertDialogActions: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
AlertDialogCancelButton: ({ children }: { children?: React.ReactNode }) => <button>{children}</button>,
|
||||||
|
AlertDialogConfirmButton: ({ children, onClick, disabled }: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) => <button onClick={onClick} disabled={disabled}>{children}</button>,
|
||||||
|
AlertDialogContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
AlertDialogDescription: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
AlertDialogTitle: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
|
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
|
||||||
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
|
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
|
||||||
const toastMocks = vi.hoisted(() => ({
|
const toastMocks = vi.hoisted(() => ({
|
||||||
@ -36,6 +61,8 @@ let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z'
|
|||||||
let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z'
|
let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z'
|
||||||
let mockPipelineId: string | undefined = 'pipeline-123'
|
let mockPipelineId: string | undefined = 'pipeline-123'
|
||||||
let mockIsAllowPublishAsCustom = true
|
let mockIsAllowPublishAsCustom = true
|
||||||
|
const mockUseBoolean = vi.hoisted(() => vi.fn())
|
||||||
|
const mockUseKeyPress = vi.hoisted(() => vi.fn())
|
||||||
vi.mock('@/next/navigation', () => ({
|
vi.mock('@/next/navigation', () => ({
|
||||||
useParams: () => ({ datasetId: 'ds-123' }),
|
useParams: () => ({ datasetId: 'ds-123' }),
|
||||||
useRouter: () => ({ push: mockPush }),
|
useRouter: () => ({ push: mockPush }),
|
||||||
@ -48,14 +75,8 @@ vi.mock('@/next/link', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('ahooks', () => ({
|
vi.mock('ahooks', () => ({
|
||||||
useBoolean: (initial: boolean) => {
|
useBoolean: (initial: boolean) => mockUseBoolean(initial),
|
||||||
const state = { value: initial }
|
useKeyPress: (...args: unknown[]) => mockUseKeyPress(...args),
|
||||||
return [state.value, {
|
|
||||||
setFalse: vi.fn(),
|
|
||||||
setTrue: vi.fn(),
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
useKeyPress: vi.fn(),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/workflow/store', () => ({
|
vi.mock('@/app/components/workflow/store', () => ({
|
||||||
@ -126,7 +147,8 @@ vi.mock('@/context/i18n', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/context/modal-context', () => ({
|
vi.mock('@/context/modal-context', () => ({
|
||||||
useModalContextSelector: () => mockSetShowPricingModal,
|
useModalContextSelector: <T,>(selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T) =>
|
||||||
|
selector({ setShowPricingModal: mockSetShowPricingModal }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/context/provider-context', () => ({
|
vi.mock('@/context/provider-context', () => ({
|
||||||
@ -194,6 +216,11 @@ describe('Popup', () => {
|
|||||||
mockDraftUpdatedAt = '2024-06-01T00:00:00Z'
|
mockDraftUpdatedAt = '2024-06-01T00:00:00Z'
|
||||||
mockPipelineId = 'pipeline-123'
|
mockPipelineId = 'pipeline-123'
|
||||||
mockIsAllowPublishAsCustom = true
|
mockIsAllowPublishAsCustom = true
|
||||||
|
mockUseBoolean.mockImplementation((initial: boolean) => [initial, {
|
||||||
|
setFalse: vi.fn(),
|
||||||
|
setTrue: vi.fn(),
|
||||||
|
}])
|
||||||
|
mockUseKeyPress.mockImplementation(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -289,12 +316,61 @@ describe('Popup', () => {
|
|||||||
describe('Publish As Knowledge Pipeline', () => {
|
describe('Publish As Knowledge Pipeline', () => {
|
||||||
it('should show pricing modal when not allowed', () => {
|
it('should show pricing modal when not allowed', () => {
|
||||||
mockIsAllowPublishAsCustom = false
|
mockIsAllowPublishAsCustom = false
|
||||||
render(<Popup />)
|
const onRequestClose = vi.fn()
|
||||||
|
render(<Popup onRequestClose={onRequestClose} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
|
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
|
||||||
|
|
||||||
|
expect(onRequestClose).toHaveBeenCalledTimes(1)
|
||||||
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should request closing the outer popover before opening publish-as modal', () => {
|
||||||
|
const onRequestClose = vi.fn()
|
||||||
|
render(<Popup onRequestClose={onRequestClose} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
|
||||||
|
|
||||||
|
expect(onRequestClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Overlay cleanup', () => {
|
||||||
|
it('should close confirm dialog when alert dialog requests close', () => {
|
||||||
|
const hideConfirm = vi.fn()
|
||||||
|
mockUseBoolean
|
||||||
|
.mockImplementationOnce(() => [true, { setFalse: hideConfirm, setTrue: vi.fn() }])
|
||||||
|
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||||
|
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||||
|
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||||
|
|
||||||
|
render(<Popup />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('alert-dialog-close'))
|
||||||
|
|
||||||
|
expect(hideConfirm).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Publish params', () => {
|
||||||
|
it('should publish as template with empty pipeline id fallback', async () => {
|
||||||
|
mockPipelineId = undefined
|
||||||
|
mockUseBoolean
|
||||||
|
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||||
|
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||||
|
.mockImplementationOnce(() => [true, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||||
|
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||||
|
render(<Popup />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('publish-as-confirm'))
|
||||||
|
|
||||||
|
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
|
||||||
|
pipelineId: '',
|
||||||
|
name: 'My Pipeline',
|
||||||
|
icon_info: { icon_type: 'emoji' },
|
||||||
|
description: 'desc',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Time formatting', () => {
|
describe('Time formatting', () => {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import { RiArrowDownSLine } from '@remixicon/react'
|
import { RiArrowDownSLine } from '@remixicon/react'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
@ -6,11 +7,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
|
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
|
||||||
import Popup from './popup'
|
import Popup from './popup'
|
||||||
|
|
||||||
@ -26,28 +22,31 @@ const Publisher = () => {
|
|||||||
}, [handleSyncWorkflowDraft])
|
}, [handleSyncWorkflowDraft])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={handleOpenChange}
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: 40,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => handleOpenChange(!open)}>
|
<PopoverTrigger
|
||||||
<Button
|
nativeButton
|
||||||
className="px-2"
|
render={(
|
||||||
variant="primary"
|
<Button
|
||||||
>
|
className="px-2"
|
||||||
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
|
variant="primary"
|
||||||
<RiArrowDownSLine className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
|
||||||
</PortalToFollowElemTrigger>
|
<RiArrowDownSLine className="h-4 w-4" />
|
||||||
<PortalToFollowElemContent className="z-11">
|
</Button>
|
||||||
<Popup />
|
)}
|
||||||
</PortalToFollowElemContent>
|
/>
|
||||||
</PortalToFollowElem>
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={40}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
|
<Popup onRequestClose={() => handleOpenChange(false)} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,11 @@ import { usePublishWorkflow } from '@/service/use-workflow'
|
|||||||
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
||||||
|
|
||||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||||
const Popup = () => {
|
type PopupProps = {
|
||||||
|
onRequestClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Popup = ({ onRequestClose }: PopupProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { datasetId } = useParams()
|
const { datasetId } = useParams()
|
||||||
const { push } = useRouter()
|
const { push } = useRouter()
|
||||||
@ -70,6 +74,7 @@ const Popup = () => {
|
|||||||
const checked = await handleCheckBeforePublish()
|
const checked = await handleCheckBeforePublish()
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!publishedAt && !confirmVisible) {
|
if (!publishedAt && !confirmVisible) {
|
||||||
|
onRequestClose?.()
|
||||||
showConfirm()
|
showConfirm()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -114,7 +119,7 @@ const Popup = () => {
|
|||||||
if (confirmVisible)
|
if (confirmVisible)
|
||||||
hideConfirm()
|
hideConfirm()
|
||||||
}
|
}
|
||||||
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm])
|
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm, onRequestClose])
|
||||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (published)
|
if (published)
|
||||||
@ -155,13 +160,14 @@ const Popup = () => {
|
|||||||
hidePublishingAsCustomizedPipeline()
|
hidePublishingAsCustomizedPipeline()
|
||||||
hidePublishAsKnowledgePipelineModal()
|
hidePublishAsKnowledgePipelineModal()
|
||||||
}
|
}
|
||||||
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal])
|
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
|
||||||
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
||||||
|
onRequestClose?.()
|
||||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
|
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
|
||||||
setShowPricingModal()
|
setShowPricingModal()
|
||||||
else
|
else
|
||||||
setShowPublishAsKnowledgePipelineModal()
|
setShowPublishAsKnowledgePipelineModal()
|
||||||
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
||||||
return (
|
return (
|
||||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
||||||
<div className="p-4 pt-3">
|
<div className="p-4 pt-3">
|
||||||
|
|||||||
@ -86,4 +86,53 @@ describe('NodeSelector', () => {
|
|||||||
expect(reopenedInput.value).toBe('')
|
expect(reopenedInput.value).toBe('')
|
||||||
expect(screen.getByText('End')).toBeInTheDocument()
|
expect(screen.getByText('End')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not open or emit open changes when disabled', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
|
||||||
|
renderWorkflowComponent(
|
||||||
|
<NodeSelector
|
||||||
|
disabled
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||||
|
availableBlocksTypes={[BlockEnum.LLM]}
|
||||||
|
trigger={open => (
|
||||||
|
<button type="button">
|
||||||
|
{open ? 'selector-open' : 'selector-closed'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'selector-closed' }))
|
||||||
|
|
||||||
|
expect(onOpenChange).not.toHaveBeenCalled()
|
||||||
|
expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the child trigger click handler when rendered as child', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onTriggerClick = vi.fn()
|
||||||
|
|
||||||
|
renderWorkflowComponent(
|
||||||
|
<NodeSelector
|
||||||
|
asChild
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||||
|
availableBlocksTypes={[BlockEnum.LLM]}
|
||||||
|
trigger={() => (
|
||||||
|
<button type="button" onClick={onTriggerClick}>
|
||||||
|
open-selector
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'open-selector' }))
|
||||||
|
|
||||||
|
expect(onTriggerClick).toHaveBeenCalledTimes(1)
|
||||||
|
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
FC,
|
FC,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import type {
|
import type {
|
||||||
CommonNodeType,
|
CommonNodeType,
|
||||||
@ -12,6 +13,12 @@ import type {
|
|||||||
OnSelectBlock,
|
OnSelectBlock,
|
||||||
ToolWithProvider,
|
ToolWithProvider,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
|
import * as React from 'react'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@ -23,11 +30,6 @@ import {
|
|||||||
Plus02,
|
Plus02,
|
||||||
} from '@/app/components/base/icons/src/vender/line/general'
|
} from '@/app/components/base/icons/src/vender/line/general'
|
||||||
import Input from '@/app/components/base/input'
|
import Input from '@/app/components/base/input'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||||
import { BlockEnum, isTriggerNode } from '../types'
|
import { BlockEnum, isTriggerNode } from '../types'
|
||||||
@ -121,6 +123,9 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||||
|
if (disabled)
|
||||||
|
return
|
||||||
|
|
||||||
setLocalOpen(newOpen)
|
setLocalOpen(newOpen)
|
||||||
|
|
||||||
if (!newOpen)
|
if (!newOpen)
|
||||||
@ -128,13 +133,10 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
|
|
||||||
if (onOpenChange)
|
if (onOpenChange)
|
||||||
onOpenChange(newOpen)
|
onOpenChange(newOpen)
|
||||||
}, [onOpenChange])
|
}, [disabled, onOpenChange])
|
||||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
const handleTrigger = useCallback<MouseEventHandler<HTMLElement>>((e) => {
|
||||||
if (disabled)
|
|
||||||
return
|
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleOpenChange(!open)
|
}, [])
|
||||||
}, [handleOpenChange, open, disabled])
|
|
||||||
|
|
||||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||||
handleOpenChange(false)
|
handleOpenChange(false)
|
||||||
@ -174,36 +176,61 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
return ''
|
return ''
|
||||||
}, [activeTab, t])
|
}, [activeTab, t])
|
||||||
|
|
||||||
|
const defaultTriggerElement = (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
z-10 flex h-4
|
||||||
|
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
||||||
|
${triggerClassName?.(open)}
|
||||||
|
`}
|
||||||
|
style={triggerStyle}
|
||||||
|
>
|
||||||
|
<Plus02 className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
const triggerElement = trigger ? trigger(open) : defaultTriggerElement
|
||||||
|
const triggerElementProps = React.isValidElement(triggerElement)
|
||||||
|
? (triggerElement.props as {
|
||||||
|
onClick?: MouseEventHandler<HTMLElement>
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
const resolvedTriggerElement = asChild && React.isValidElement(triggerElement)
|
||||||
|
? React.cloneElement(
|
||||||
|
triggerElement as React.ReactElement<{
|
||||||
|
onClick?: MouseEventHandler<HTMLElement>
|
||||||
|
}>,
|
||||||
|
{
|
||||||
|
onClick: (e: ReactMouseEvent<HTMLElement>) => {
|
||||||
|
handleTrigger(e)
|
||||||
|
if (typeof triggerElementProps?.onClick === 'function')
|
||||||
|
triggerElementProps.onClick(e)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className={triggerInnerClassName} onClick={handleTrigger}>
|
||||||
|
{triggerElement}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset
|
||||||
|
const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0)
|
||||||
|
const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0)
|
||||||
|
const nativeButton = asChild
|
||||||
|
&& React.isValidElement(triggerElement)
|
||||||
|
&& (typeof triggerElement.type !== 'string' || triggerElement.type === 'button')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement={placement}
|
|
||||||
offset={offset}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger nativeButton={nativeButton} render={resolvedTriggerElement as React.ReactElement} />
|
||||||
asChild={asChild}
|
<PopoverContent
|
||||||
onClick={handleTrigger}
|
placement={placement}
|
||||||
className={triggerInnerClassName}
|
sideOffset={sideOffset}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
>
|
>
|
||||||
{
|
|
||||||
trigger
|
|
||||||
? trigger(open)
|
|
||||||
: (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
z-10 flex h-4
|
|
||||||
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
|
||||||
${triggerClassName?.(open)}
|
|
||||||
`}
|
|
||||||
style={triggerStyle}
|
|
||||||
>
|
|
||||||
<Plus02 className="h-2.5 w-2.5" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent className="z-1002">
|
|
||||||
<div className={`rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg ${popupClassName}`}>
|
<div className={`rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg ${popupClassName}`}>
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
@ -270,8 +297,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
|||||||
forceShowStartContent={forceShowStartContent}
|
forceShowStartContent={forceShowStartContent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,14 +10,13 @@ const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
|
|||||||
const mockCloseAllInputFieldPanels = vi.fn()
|
const mockCloseAllInputFieldPanels = vi.fn()
|
||||||
const mockHandleNodesCancelSelected = vi.fn()
|
const mockHandleNodesCancelSelected = vi.fn()
|
||||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||||
|
const mockHandleBackupDraft = vi.fn()
|
||||||
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
|
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
|
||||||
|
|
||||||
let mockIsChatMode = false
|
let mockIsChatMode = false
|
||||||
|
|
||||||
vi.mock('../../hooks', async () => {
|
vi.mock('../../hooks', () => {
|
||||||
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
|
|
||||||
return {
|
return {
|
||||||
...actual,
|
|
||||||
useIsChatMode: () => mockIsChatMode,
|
useIsChatMode: () => mockIsChatMode,
|
||||||
useNodesInteractions: () => ({
|
useNodesInteractions: () => ({
|
||||||
handleNodesCancelSelected: mockHandleNodesCancelSelected,
|
handleNodesCancelSelected: mockHandleNodesCancelSelected,
|
||||||
@ -25,6 +24,9 @@ vi.mock('../../hooks', async () => {
|
|||||||
useWorkflowInteractions: () => ({
|
useWorkflowInteractions: () => ({
|
||||||
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
||||||
}),
|
}),
|
||||||
|
useWorkflowRun: () => ({
|
||||||
|
handleBackupDraft: mockHandleBackupDraft,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -48,38 +50,46 @@ vi.mock('@/app/components/base/loading', () => ({
|
|||||||
default: () => <div data-testid="loading" />,
|
default: () => <div data-testid="loading" />,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/tooltip', () => ({
|
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||||
default: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||||
const PortalContext = React.createContext({ open: false })
|
Button: ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||||
|
<button {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
return {
|
vi.mock('@langgenius/dify-ui/tooltip', () => ({
|
||||||
PortalToFollowElem: ({
|
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||||
children,
|
TooltipTrigger: ({
|
||||||
open,
|
children,
|
||||||
}: {
|
render,
|
||||||
children?: React.ReactNode
|
}: {
|
||||||
open: boolean
|
children?: React.ReactNode
|
||||||
}) => <PortalContext.Provider value={{ open }}>{children}</PortalContext.Provider>,
|
render?: React.ReactElement
|
||||||
PortalToFollowElemTrigger: ({
|
}) => {
|
||||||
children,
|
if (render && React.isValidElement(render)) {
|
||||||
onClick,
|
const renderElement = render as React.ReactElement<{ children?: React.ReactNode }>
|
||||||
}: {
|
return React.cloneElement(renderElement, renderElement.props, children)
|
||||||
children?: React.ReactNode
|
}
|
||||||
onClick?: () => void
|
|
||||||
}) => <div data-testid="portal-trigger" onClick={onClick}>{children}</div>,
|
return <>{children}</>
|
||||||
PortalToFollowElemContent: ({
|
},
|
||||||
children,
|
TooltipContent: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||||
}: {
|
}))
|
||||||
children?: React.ReactNode
|
|
||||||
}) => {
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
const { open } = React.useContext(PortalContext)
|
|
||||||
return open ? <div data-testid="portal-content">{children}</div> : null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('../../utils', async () => {
|
vi.mock('../../utils', async () => {
|
||||||
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
|
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
|
||||||
@ -130,7 +140,7 @@ describe('ViewHistory', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
|
expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
|
||||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
||||||
|
|
||||||
@ -165,7 +175,6 @@ describe('ViewHistory', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders workflow run history items and updates the workflow store when one is selected', () => {
|
it('renders workflow run history items and updates the workflow store when one is selected', () => {
|
||||||
const handleBackupDraft = vi.fn()
|
|
||||||
const pausedRun = createHistoryItem({
|
const pausedRun = createHistoryItem({
|
||||||
id: 'run-paused',
|
id: 'run-paused',
|
||||||
status: WorkflowRunningStatus.Paused,
|
status: WorkflowRunningStatus.Paused,
|
||||||
@ -199,9 +208,6 @@ describe('ViewHistory', () => {
|
|||||||
showEnvPanel: true,
|
showEnvPanel: true,
|
||||||
controlMode: ControlMode.Pointer,
|
controlMode: ControlMode.Pointer,
|
||||||
},
|
},
|
||||||
hooksStoreProps: {
|
|
||||||
handleBackupDraft,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
|
||||||
@ -217,7 +223,7 @@ describe('ViewHistory', () => {
|
|||||||
expect(store.getState().showEnvPanel).toBe(false)
|
expect(store.getState().showEnvPanel).toBe(false)
|
||||||
expect(store.getState().controlMode).toBe(ControlMode.Hand)
|
expect(store.getState().controlMode).toBe(ControlMode.Hand)
|
||||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||||
expect(handleBackupDraft).toHaveBeenCalledTimes(1)
|
expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1)
|
||||||
expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
|
expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
|
||||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
|
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
@ -271,6 +277,6 @@ describe('ViewHistory', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||||
|
|
||||||
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
|
expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
|
||||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@langgenius/dify-ui/tooltip'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import Tooltip from '@/app/components/base/tooltip'
|
|
||||||
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
|
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
|
||||||
import {
|
import {
|
||||||
useStore,
|
useStore,
|
||||||
@ -61,52 +65,60 @@ const ViewHistory = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement={withText ? 'bottom-start' : 'bottom-end'}
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: withText ? -8 : 10,
|
|
||||||
}}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
{withText
|
||||||
{
|
? (
|
||||||
withText && (
|
<PopoverTrigger
|
||||||
<button
|
render={(
|
||||||
type="button"
|
<button
|
||||||
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
|
type="button"
|
||||||
className={cn(
|
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
|
||||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
className={cn(
|
||||||
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
||||||
open && 'bg-components-button-secondary-bg-hover',
|
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||||
|
open && 'bg-components-button-secondary-bg-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="mr-1 i-custom-vender-line-time-clock-play h-4 w-4" />
|
||||||
|
{t('common.showRunHistory', { ns: 'workflow' })}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<span className="mr-1 i-custom-vender-line-time-clock-play h-4 w-4" />
|
|
||||||
{t('common.showRunHistory', { ns: 'workflow' })}
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}
|
: (
|
||||||
{
|
<Tooltip>
|
||||||
!withText && (
|
<TooltipTrigger
|
||||||
<Tooltip
|
render={<div className="flex" />}
|
||||||
popupContent={t('common.viewRunHistory', { ns: 'workflow' })}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
|
|
||||||
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
|
||||||
onClick={() => {
|
|
||||||
onClearLogAndMessageModal?.()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
|
<PopoverTrigger
|
||||||
</button>
|
render={(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||||
|
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||||
|
onClick={() => {
|
||||||
|
onClearLogAndMessageModal?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t('common.viewRunHistory', { ns: 'workflow' })}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)}
|
||||||
}
|
<PopoverContent
|
||||||
</PortalToFollowElemTrigger>
|
placement={withText ? 'bottom-start' : 'bottom-end'}
|
||||||
<PortalToFollowElemContent className="z-12">
|
sideOffset={4}
|
||||||
|
alignOffset={withText ? -8 : 10}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="ml-2 flex w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
|
className="ml-2 flex w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
|
||||||
style={{
|
style={{
|
||||||
@ -207,8 +219,8 @@ const ViewHistory = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
RiHistoryLine,
|
RiHistoryLine,
|
||||||
@ -13,11 +18,6 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import Divider from '../../base/divider'
|
import Divider from '../../base/divider'
|
||||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||||
import {
|
import {
|
||||||
@ -91,12 +91,20 @@ const ViewWorkflowHistory = () => {
|
|||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const calculateChangeList: ChangeHistoryList = useMemo(() => {
|
const calculateChangeList: ChangeHistoryList = useMemo(() => {
|
||||||
const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial<WorkflowHistoryState>, index: number) => {
|
const filterList = (
|
||||||
const nodes = (state.nodes || store.getState().nodes) || []
|
list: Array<Partial<WorkflowHistoryState> | undefined>,
|
||||||
const nodeId = state?.workflowHistoryEventMeta?.nodeId
|
startIndex = 0,
|
||||||
|
reverse = false,
|
||||||
|
) => list.flatMap((state, index) => {
|
||||||
|
if (!state)
|
||||||
|
return []
|
||||||
|
|
||||||
|
const nodes = state.nodes || store.getState().nodes || []
|
||||||
|
const nodeId = state.workflowHistoryEventMeta?.nodeId
|
||||||
const targetTitle = nodes.find(n => n.id === nodeId)?.data?.title ?? ''
|
const targetTitle = nodes.find(n => n.id === nodeId)?.data?.title ?? ''
|
||||||
return {
|
|
||||||
label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent),
|
return [{
|
||||||
|
label: state.workflowHistoryEvent ? getHistoryLabel(state.workflowHistoryEvent) : '',
|
||||||
index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
|
index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
|
||||||
state: {
|
state: {
|
||||||
...state,
|
...state,
|
||||||
@ -107,8 +115,8 @@ const ViewWorkflowHistory = () => {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
}
|
}]
|
||||||
}).filter(Boolean)
|
})
|
||||||
|
|
||||||
const historyData = {
|
const historyData = {
|
||||||
pastStates: filterList(pastStates, pastStates.length).reverse(),
|
pastStates: filterList(pastStates, pastStates.length).reverse(),
|
||||||
@ -132,35 +140,42 @@ const ViewWorkflowHistory = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: 131,
|
|
||||||
}}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (nodesReadOnly)
|
||||||
|
return
|
||||||
|
setOpen(nextOpen)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => !nodesReadOnly && setOpen(v => !v)}>
|
<TipPopup
|
||||||
<TipPopup
|
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
>
|
||||||
>
|
<PopoverTrigger
|
||||||
<div
|
nativeButton={false}
|
||||||
className={
|
render={(
|
||||||
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
<div
|
||||||
}
|
className={
|
||||||
onClick={() => {
|
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||||
if (nodesReadOnly)
|
}
|
||||||
return
|
onClick={() => {
|
||||||
setCurrentLogItem()
|
if (nodesReadOnly)
|
||||||
setShowMessageLogModal(false)
|
return
|
||||||
}}
|
setCurrentLogItem()
|
||||||
>
|
setShowMessageLogModal(false)
|
||||||
<RiHistoryLine className="h-4 w-4" />
|
}}
|
||||||
</div>
|
>
|
||||||
</TipPopup>
|
<RiHistoryLine className="h-4 w-4" />
|
||||||
</PortalToFollowElemTrigger>
|
</div>
|
||||||
<PortalToFollowElemContent className="z-12">
|
)}
|
||||||
|
/>
|
||||||
|
</TipPopup>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={131}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="ml-2 flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
|
className="ml-2 flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
|
||||||
>
|
>
|
||||||
@ -293,8 +308,8 @@ const ViewWorkflowHistory = () => {
|
|||||||
<div className="mb-1 leading-[18px] text-text-tertiary">{t('changeHistory.hintText', { ns: 'workflow' })}</div>
|
<div className="mb-1 leading-[18px] text-text-tertiary">{t('changeHistory.hintText', { ns: 'workflow' })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,42 +21,7 @@ vi.mock('@langgenius/dify-ui/button', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
const OpenContext = React.createContext(false)
|
|
||||||
|
|
||||||
return {
|
|
||||||
PortalToFollowElem: ({
|
|
||||||
open,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) => (
|
|
||||||
<OpenContext value={open}>
|
|
||||||
<div data-testid="portal" data-open={String(open)}>{children}</div>
|
|
||||||
</OpenContext>
|
|
||||||
),
|
|
||||||
PortalToFollowElemTrigger: ({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid="portal-trigger" onClick={onClick}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
PortalToFollowElemContent: ({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) => {
|
|
||||||
const open = React.use(OpenContext)
|
|
||||||
return open ? <div data-testid="portal-content">{children}</div> : null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ButtonStyleDropdown', () => {
|
describe('ButtonStyleDropdown', () => {
|
||||||
const onChange = vi.fn()
|
const onChange = vi.fn()
|
||||||
@ -80,10 +45,10 @@ describe('ButtonStyleDropdown', () => {
|
|||||||
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
}))
|
}))
|
||||||
expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'false')
|
expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false')
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'true')
|
expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'true')
|
||||||
expect(screen.getByText('nodes.humanInput.userActions.chooseStyle'))!.toBeInTheDocument()
|
expect(screen.getByText('nodes.humanInput.userActions.chooseStyle'))!.toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement)
|
fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement)
|
||||||
@ -111,10 +76,10 @@ describe('ButtonStyleDropdown', () => {
|
|||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'false')
|
expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false')
|
||||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||||
expect(onChange).not.toHaveBeenCalled()
|
expect(onChange).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
RiFontSize,
|
RiFontSize,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { UserActionButtonType } from '../types'
|
import { UserActionButtonType } from '../types'
|
||||||
|
|
||||||
const i18nPrefix = 'nodes.humanInput'
|
const i18nPrefix = 'nodes.humanInput'
|
||||||
@ -45,23 +45,29 @@ const ButtonStyleDropdown: FC<Props> = ({
|
|||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open && !readonly}
|
open={open && !readonly}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
placement="bottom-end"
|
if (readonly)
|
||||||
offset={{
|
return
|
||||||
mainAxis: 4,
|
setOpen(nextOpen)
|
||||||
crossAxis: 44,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)}>
|
<PopoverTrigger
|
||||||
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
|
render={(
|
||||||
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
|
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
|
||||||
<RiFontSize className="h-4 w-4" />
|
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
|
||||||
</Button>
|
<RiFontSize className="h-4 w-4" />
|
||||||
</div>
|
</Button>
|
||||||
</PortalToFollowElemTrigger>
|
</div>
|
||||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={44}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-xs">
|
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-xs">
|
||||||
<div className="system-md-medium text-text-primary">{t(`${i18nPrefix}.userActions.chooseStyle`, { ns: 'workflow' })}</div>
|
<div className="system-md-medium text-text-primary">{t(`${i18nPrefix}.userActions.chooseStyle`, { ns: 'workflow' })}</div>
|
||||||
<div className="mt-2 flex w-[324px] flex-wrap gap-1">
|
<div className="mt-2 flex w-[324px] flex-wrap gap-1">
|
||||||
@ -103,8 +109,8 @@ const ButtonStyleDropdown: FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
|
import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { RiFilter3Line } from '@remixicon/react'
|
import { RiFilter3Line } from '@remixicon/react'
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import MetadataPanel from './metadata-panel'
|
import MetadataPanel from './metadata-panel'
|
||||||
|
|
||||||
const MetadataTrigger = ({
|
const MetadataTrigger = ({
|
||||||
@ -40,25 +40,29 @@ const MetadataTrigger = ({
|
|||||||
}, [metadataFilteringConditions, metadataList, handleRemoveCondition, selectedDatasetsLoaded])
|
}, [metadataFilteringConditions, metadataList, handleRemoveCondition, selectedDatasetsLoaded])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement="left"
|
|
||||||
offset={4}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
<PopoverTrigger
|
||||||
<Button
|
render={(
|
||||||
variant="secondary-accent"
|
<Button
|
||||||
size="small"
|
variant="secondary-accent"
|
||||||
>
|
size="small"
|
||||||
<RiFilter3Line className="mr-1 h-3.5 w-3.5" />
|
>
|
||||||
{t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })}
|
<RiFilter3Line className="mr-1 h-3.5 w-3.5" />
|
||||||
<div className="ml-1 flex items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary">
|
{t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })}
|
||||||
{metadataFilteringConditions?.conditions.length || 0}
|
<div className="ml-1 flex items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||||
</div>
|
{metadataFilteringConditions?.conditions.length || 0}
|
||||||
</Button>
|
</div>
|
||||||
</PortalToFollowElemTrigger>
|
</Button>
|
||||||
<PortalToFollowElemContent className="z-10">
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="left"
|
||||||
|
sideOffset={4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<MetadataPanel
|
<MetadataPanel
|
||||||
metadataFilteringConditions={metadataFilteringConditions}
|
metadataFilteringConditions={metadataFilteringConditions}
|
||||||
onCancel={() => setOpen(false)}
|
onCancel={() => setOpen(false)}
|
||||||
@ -66,8 +70,8 @@ const MetadataTrigger = ({
|
|||||||
handleRemoveCondition={handleRemoveCondition}
|
handleRemoveCondition={handleRemoveCondition}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,16 +7,16 @@ import type { DataSet } from '@/models/datasets'
|
|||||||
import type { DatasetConfigs } from '@/models/debug'
|
import type { DatasetConfigs } from '@/models/debug'
|
||||||
import { Button } from '@langgenius/dify-ui/button'
|
import { Button } from '@langgenius/dify-ui/button'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { RiEqualizer2Line } from '@remixicon/react'
|
import { RiEqualizer2Line } from '@remixicon/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ConfigRetrievalContent from '@/app/components/app/configuration/dataset-config/params-config/config-content'
|
import ConfigRetrievalContent from '@/app/components/app/configuration/dataset-config/params-config/config-content'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { DATASET_DEFAULT } from '@/config'
|
import { DATASET_DEFAULT } from '@/config'
|
||||||
import { RETRIEVE_TYPE } from '@/types/app'
|
import { RETRIEVE_TYPE } from '@/types/app'
|
||||||
|
|
||||||
@ -114,32 +114,33 @@ const RetrievalConfig: FC<Props> = ({
|
|||||||
}, [onMultipleRetrievalConfigChange, retrieval_mode, onRetrievalModeChange])
|
}, [onMultipleRetrievalConfigChange, retrieval_mode, onRetrievalModeChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={rerankModalOpen}
|
open={rerankModalOpen}
|
||||||
onOpenChange={handleOpen}
|
onOpenChange={(nextOpen) => {
|
||||||
placement="bottom-end"
|
if (readonly)
|
||||||
offset={{
|
return
|
||||||
crossAxis: -2,
|
handleOpen(nextOpen)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger
|
<PopoverTrigger
|
||||||
onClick={() => {
|
render={(
|
||||||
if (readonly)
|
<Button
|
||||||
return
|
variant="ghost"
|
||||||
handleOpen(!rerankModalOpen)
|
size="small"
|
||||||
}}
|
disabled={readonly}
|
||||||
|
className={cn(rerankModalOpen && 'bg-components-button-ghost-bg-hover')}
|
||||||
|
>
|
||||||
|
<RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{t('retrievalSettings', { ns: 'dataset' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={0}
|
||||||
|
alignOffset={-2}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
>
|
>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
disabled={readonly}
|
|
||||||
className={cn(rerankModalOpen && 'bg-components-button-ghost-bg-hover')}
|
|
||||||
>
|
|
||||||
<RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />
|
|
||||||
{t('retrievalSettings', { ns: 'dataset' })}
|
|
||||||
</Button>
|
|
||||||
</PortalToFollowElemTrigger>
|
|
||||||
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
|
|
||||||
<div className="w-[404px] rounded-2xl border border-components-panel-border bg-components-panel-bg px-4 pt-3 pb-4 shadow-xl">
|
<div className="w-[404px] rounded-2xl border border-components-panel-border bg-components-panel-bg px-4 pt-3 pb-4 shadow-xl">
|
||||||
<ConfigRetrievalContent
|
<ConfigRetrievalContent
|
||||||
datasetConfigs={datasetConfigs}
|
datasetConfigs={datasetConfigs}
|
||||||
@ -151,8 +152,8 @@ const RetrievalConfig: FC<Props> = ({
|
|||||||
onSingleRetrievalModelParamsChange={onSingleRetrievalModelParamsChange}
|
onSingleRetrievalModelParamsChange={onSingleRetrievalModelParamsChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default React.memo(RetrievalConfig)
|
export default React.memo(RetrievalConfig)
|
||||||
|
|||||||
@ -3,14 +3,14 @@ import type { SchemaRoot } from '../../../types'
|
|||||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
import type { CompletionParams, Model } from '@/types/app'
|
import type { CompletionParams, Model } from '@/types/app'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
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 { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||||
import useTheme from '@/hooks/use-theme'
|
import useTheme from '@/hooks/use-theme'
|
||||||
@ -27,61 +27,68 @@ type JsonSchemaGeneratorProps = {
|
|||||||
crossAxisOffset?: number
|
crossAxisOffset?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GeneratorView {
|
const GENERATOR_VIEWS = {
|
||||||
promptEditor = 'promptEditor',
|
promptEditor: 'promptEditor',
|
||||||
result = 'result',
|
result: 'result',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type GeneratorView = typeof GENERATOR_VIEWS[keyof typeof GENERATOR_VIEWS]
|
||||||
|
|
||||||
|
const createEmptyModel = (): Model => ({
|
||||||
|
name: '',
|
||||||
|
provider: '',
|
||||||
|
mode: ModelModeType.completion,
|
||||||
|
completion_params: {} as CompletionParams,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStoredModel = (): Model | null => {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return null
|
||||||
|
|
||||||
|
const savedModel = window.localStorage.getItem('auto-gen-model')
|
||||||
|
|
||||||
|
if (!savedModel)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return JSON.parse(savedModel) as Model
|
||||||
}
|
}
|
||||||
|
|
||||||
const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||||
onApply,
|
onApply,
|
||||||
crossAxisOffset,
|
crossAxisOffset,
|
||||||
}) => {
|
}) => {
|
||||||
const localModel = localStorage.getItem('auto-gen-model')
|
|
||||||
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
|
|
||||||
: null
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [view, setView] = useState(GeneratorView.promptEditor)
|
const [view, setView] = useState<GeneratorView>(GENERATOR_VIEWS.promptEditor)
|
||||||
const [model, setModel] = useState<Model>(localModel || {
|
const [model, setModel] = useState<Model | null>(() => getStoredModel())
|
||||||
name: '',
|
|
||||||
provider: '',
|
|
||||||
mode: ModelModeType.completion,
|
|
||||||
completion_params: {} as CompletionParams,
|
|
||||||
})
|
|
||||||
const [instruction, setInstruction] = useState('')
|
const [instruction, setInstruction] = useState('')
|
||||||
const [schema, setSchema] = useState<SchemaRoot | null>(null)
|
const [schema, setSchema] = useState<SchemaRoot | null>(null)
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const {
|
const {
|
||||||
defaultModel,
|
defaultModel,
|
||||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||||
|
const resolvedModel = React.useMemo<Model>(() => {
|
||||||
|
if (model)
|
||||||
|
return model
|
||||||
|
|
||||||
|
if (!defaultModel)
|
||||||
|
return createEmptyModel()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...createEmptyModel(),
|
||||||
|
name: defaultModel.model,
|
||||||
|
provider: defaultModel.provider.provider,
|
||||||
|
}
|
||||||
|
}, [defaultModel, model])
|
||||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||||
const { emit } = useMittContext()
|
const { emit } = useMittContext()
|
||||||
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
|
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (defaultModel) {
|
|
||||||
const localModel = localStorage.getItem('auto-gen-model')
|
|
||||||
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
|
|
||||||
: null
|
|
||||||
if (localModel) {
|
|
||||||
setModel(localModel)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setModel(prev => ({
|
|
||||||
...prev,
|
|
||||||
name: defaultModel.model,
|
|
||||||
provider: defaultModel.provider.provider,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [defaultModel])
|
|
||||||
|
|
||||||
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (advancedEditing || isAddingNewField)
|
if (advancedEditing || isAddingNewField)
|
||||||
emit('quitEditing', {})
|
emit('quitEditing', {})
|
||||||
setOpen(!open)
|
}, [advancedEditing, isAddingNewField, emit])
|
||||||
}, [open, advancedEditing, isAddingNewField, emit])
|
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@ -89,39 +96,39 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
|||||||
|
|
||||||
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
|
const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => {
|
||||||
const newModel = {
|
const newModel = {
|
||||||
...model,
|
...resolvedModel,
|
||||||
provider: newValue.provider,
|
provider: newValue.provider,
|
||||||
name: newValue.modelId,
|
name: newValue.modelId,
|
||||||
mode: newValue.mode as ModelModeType,
|
mode: newValue.mode as ModelModeType,
|
||||||
}
|
}
|
||||||
setModel(newModel)
|
setModel(newModel)
|
||||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
window.localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||||
}, [model, setModel])
|
}, [resolvedModel])
|
||||||
|
|
||||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||||
const newModel = {
|
const newModel = {
|
||||||
...model,
|
...resolvedModel,
|
||||||
completion_params: newParams as CompletionParams,
|
completion_params: newParams as CompletionParams,
|
||||||
}
|
}
|
||||||
setModel(newModel)
|
setModel(newModel)
|
||||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
window.localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||||
}, [model, setModel])
|
}, [resolvedModel])
|
||||||
|
|
||||||
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()
|
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()
|
||||||
|
|
||||||
const generateSchema = useCallback(async () => {
|
const generateSchema = useCallback(async () => {
|
||||||
const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! })
|
const { output, error } = await generateStructuredOutputRules({ instruction, model_config: resolvedModel })
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error)
|
toast.error(error)
|
||||||
setSchema(null)
|
setSchema(null)
|
||||||
setView(GeneratorView.promptEditor)
|
setView(GENERATOR_VIEWS.promptEditor)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return output
|
return output
|
||||||
}, [instruction, model, generateStructuredOutputRules])
|
}, [generateStructuredOutputRules, instruction, resolvedModel])
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
setView(GeneratorView.result)
|
setView(GENERATOR_VIEWS.result)
|
||||||
const output = await generateSchema()
|
const output = await generateSchema()
|
||||||
if (output === undefined)
|
if (output === undefined)
|
||||||
return
|
return
|
||||||
@ -129,7 +136,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
|||||||
}, [generateSchema])
|
}, [generateSchema])
|
||||||
|
|
||||||
const goBackToPromptEditor = () => {
|
const goBackToPromptEditor = () => {
|
||||||
setView(GeneratorView.promptEditor)
|
setView(GENERATOR_VIEWS.promptEditor)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRegenerate = useCallback(async () => {
|
const handleRegenerate = useCallback(async () => {
|
||||||
@ -145,31 +152,34 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: crossAxisOffset ?? 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
<PopoverTrigger
|
||||||
<button
|
render={(
|
||||||
type="button"
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'flex h-6 w-6 items-center justify-center rounded-md p-0.5 hover:bg-state-accent-hover',
|
onClick={handleTrigger}
|
||||||
open && 'bg-state-accent-active',
|
className={cn(
|
||||||
)}
|
'flex h-6 w-6 items-center justify-center rounded-md p-0.5 hover:bg-state-accent-hover',
|
||||||
>
|
open && 'bg-state-accent-active',
|
||||||
<SchemaGenerator />
|
)}
|
||||||
</button>
|
>
|
||||||
</PortalToFollowElemTrigger>
|
<SchemaGenerator />
|
||||||
<PortalToFollowElemContent className="z-100">
|
</button>
|
||||||
{view === GeneratorView.promptEditor && (
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={crossAxisOffset ?? 0}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
|
{view === GENERATOR_VIEWS.promptEditor && (
|
||||||
<PromptEditor
|
<PromptEditor
|
||||||
instruction={instruction}
|
instruction={instruction}
|
||||||
model={model}
|
model={resolvedModel}
|
||||||
onInstructionChange={setInstruction}
|
onInstructionChange={setInstruction}
|
||||||
onCompletionParamsChange={handleCompletionParamsChange}
|
onCompletionParamsChange={handleCompletionParamsChange}
|
||||||
onGenerate={handleGenerate}
|
onGenerate={handleGenerate}
|
||||||
@ -177,7 +187,7 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
|||||||
onModelChange={handleModelChange}
|
onModelChange={handleModelChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === GeneratorView.result && (
|
{view === GENERATOR_VIEWS.result && (
|
||||||
<GeneratedResult
|
<GeneratedResult
|
||||||
schema={schema!}
|
schema={schema!}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
@ -187,8 +197,8 @@ const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +1,32 @@
|
|||||||
import { fireEvent, render, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { NoteTheme } from '../../../types'
|
import { NoteTheme } from '../../../types'
|
||||||
import ColorPicker, { COLOR_LIST } from '../color-picker'
|
import ColorPicker from '../color-picker'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
|
|
||||||
describe('NoteEditor ColorPicker', () => {
|
describe('NoteEditor ColorPicker', () => {
|
||||||
it('should open the palette and apply the selected theme', async () => {
|
it('should open the palette and apply the selected theme', async () => {
|
||||||
const onThemeChange = vi.fn()
|
const onThemeChange = vi.fn()
|
||||||
const { container } = render(
|
render(
|
||||||
<ColorPicker theme={NoteTheme.blue} onThemeChange={onThemeChange} />,
|
<ColorPicker theme={NoteTheme.blue} onThemeChange={onThemeChange} />,
|
||||||
)
|
)
|
||||||
|
|
||||||
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
|
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||||
|
|
||||||
fireEvent.click(trigger)
|
const popup = screen.getByTestId('popover-content')
|
||||||
|
|
||||||
const popup = document.body.querySelector('[role="tooltip"]')
|
|
||||||
|
|
||||||
expect(popup).toBeInTheDocument()
|
expect(popup).toBeInTheDocument()
|
||||||
|
|
||||||
const options = popup?.querySelectorAll('.group.relative')
|
const options = popup.querySelectorAll('.group.relative')
|
||||||
|
|
||||||
expect(options).toHaveLength(COLOR_LIST.length)
|
expect(options).toHaveLength(6)
|
||||||
|
|
||||||
fireEvent.click(options?.[COLOR_LIST.length - 1] as Element)
|
fireEvent.click(options[5] as Element)
|
||||||
|
|
||||||
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
|
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import FontSizeSelector from '../font-size-selector'
|
import FontSizeSelector from '../font-size-selector'
|
||||||
|
|
||||||
|
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mockHandleFontSize,
|
mockHandleFontSize,
|
||||||
mockHandleOpenFontSizeSelector,
|
mockHandleOpenFontSizeSelector,
|
||||||
@ -52,4 +54,12 @@ describe('NoteEditor FontSizeSelector', () => {
|
|||||||
expect(mockHandleFontSize).toHaveBeenCalledWith('16px')
|
expect(mockHandleFontSize).toHaveBeenCalledWith('16px')
|
||||||
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false)
|
expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should fall back to the small label when current font size is unknown', () => {
|
||||||
|
mockFontSize = '18px'
|
||||||
|
|
||||||
|
render(<FontSizeSelector />)
|
||||||
|
|
||||||
|
expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -76,11 +76,14 @@ describe('NoteEditor Toolbar', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument()
|
expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument()
|
||||||
|
|
||||||
const triggers = container.querySelectorAll('[data-state="closed"]')
|
const buttons = container.querySelectorAll('button[type="button"]')
|
||||||
|
fireEvent.click(buttons[0] as HTMLElement)
|
||||||
|
|
||||||
fireEvent.click(triggers[0] as HTMLElement)
|
await waitFor(() => {
|
||||||
|
expect(document.body.querySelectorAll('.group.relative').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
const colorOptions = document.body.querySelectorAll('[role="tooltip"] .group.relative')
|
const colorOptions = document.body.querySelectorAll('.group.relative')
|
||||||
|
|
||||||
fireEvent.click(colorOptions[colorOptions.length - 1] as Element)
|
fireEvent.click(colorOptions[colorOptions.length - 1] as Element)
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { THEME_MAP } from '../../constants'
|
import { THEME_MAP } from '../../constants'
|
||||||
import { NoteTheme } from '../../types'
|
import { NoteTheme } from '../../types'
|
||||||
|
|
||||||
export const COLOR_LIST = [
|
const COLOR_LIST = [
|
||||||
{
|
{
|
||||||
key: NoteTheme.blue,
|
key: NoteTheme.blue,
|
||||||
inner: THEME_MAP[NoteTheme.blue]!.title,
|
inner: THEME_MAP[NoteTheme.blue]!.title,
|
||||||
@ -55,28 +55,35 @@ const ColorPicker = ({
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
placement="top"
|
|
||||||
offset={4}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
<PopoverTrigger
|
||||||
<div className={cn(
|
nativeButton
|
||||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md hover:bg-black/5',
|
render={(
|
||||||
open && 'bg-black/5',
|
<button
|
||||||
)}
|
type="button"
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-4 w-4 rounded-full border border-black/5',
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md hover:bg-black/5',
|
||||||
THEME_MAP[theme]!.title,
|
open && 'bg-black/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
</div>
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
</PortalToFollowElemTrigger>
|
'h-4 w-4 rounded-full border border-black/5',
|
||||||
<PortalToFollowElemContent>
|
THEME_MAP[theme]!.title,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="top"
|
||||||
|
sideOffset={4}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="grid grid-cols-3 grid-rows-2 gap-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg">
|
<div className="grid grid-cols-3 grid-rows-2 gap-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg">
|
||||||
{
|
{
|
||||||
COLOR_LIST.map(color => (
|
COLOR_LIST.map(color => (
|
||||||
@ -107,8 +114,8 @@ const ColorPicker = ({
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { RiFontSize } from '@remixicon/react'
|
import { RiFontSize } from '@remixicon/react'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
import { Check } from '@/app/components/base/icons/src/vender/line/general'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { useFontSize } from './hooks'
|
import { useFontSize } from './hooks'
|
||||||
|
|
||||||
const FontSizeSelector = () => {
|
const FontSizeSelector = () => {
|
||||||
@ -34,23 +34,30 @@ const FontSizeSelector = () => {
|
|||||||
} = useFontSize()
|
} = useFontSize()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
open={fontSizeSelectorShow}
|
open={fontSizeSelectorShow}
|
||||||
onOpenChange={handleOpenFontSizeSelector}
|
onOpenChange={handleOpenFontSizeSelector}
|
||||||
placement="bottom-start"
|
|
||||||
offset={2}
|
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => handleOpenFontSizeSelector(!fontSizeSelectorShow)}>
|
<PopoverTrigger
|
||||||
<div className={cn(
|
nativeButton
|
||||||
'flex h-8 cursor-pointer items-center rounded-md pr-1.5 pl-2 text-[13px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
render={(
|
||||||
fontSizeSelectorShow && 'bg-state-base-hover text-text-secondary',
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 cursor-pointer items-center rounded-md pr-1.5 pl-2 text-[13px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||||
|
fontSizeSelectorShow && 'bg-state-base-hover text-text-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiFontSize className="mr-1 h-4 w-4" />
|
||||||
|
{FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('nodes.note.editor.small', { ns: 'workflow' })}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<RiFontSize className="mr-1 h-4 w-4" />
|
<PopoverContent
|
||||||
{FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('nodes.note.editor.small', { ns: 'workflow' })}
|
placement="bottom-start"
|
||||||
</div>
|
sideOffset={2}
|
||||||
</PortalToFollowElemTrigger>
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
<PortalToFollowElemContent>
|
>
|
||||||
<div className="w-[120px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 text-text-secondary shadow-xl">
|
<div className="w-[120px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 text-text-secondary shadow-xl">
|
||||||
{
|
{
|
||||||
FONT_SIZE_LIST.map(font => (
|
FONT_SIZE_LIST.map(font => (
|
||||||
@ -77,8 +84,8 @@ const FontSizeSelector = () => {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { cn } from '@langgenius/dify-ui/cn'
|
import { cn } from '@langgenius/dify-ui/cn'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@langgenius/dify-ui/popover'
|
||||||
import { RiFilter3Line } from '@remixicon/react'
|
import { RiFilter3Line } from '@remixicon/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import Divider from '@/app/components/base/divider'
|
import Divider from '@/app/components/base/divider'
|
||||||
import {
|
|
||||||
PortalToFollowElem,
|
|
||||||
PortalToFollowElemContent,
|
|
||||||
PortalToFollowElemTrigger,
|
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
|
||||||
import { WorkflowVersionFilterOptions } from '../../../types'
|
import { WorkflowVersionFilterOptions } from '../../../types'
|
||||||
import FilterItem from './filter-item'
|
import FilterItem from './filter-item'
|
||||||
import FilterSwitch from './filter-switch'
|
import FilterSwitch from './filter-switch'
|
||||||
@ -37,26 +37,28 @@ const Filter: FC<FilterProps> = ({
|
|||||||
const isFiltering = filterValue !== WorkflowVersionFilterOptions.all || isOnlyShowNamedVersions
|
const isFiltering = filterValue !== WorkflowVersionFilterOptions.all || isOnlyShowNamedVersions
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalToFollowElem
|
<Popover
|
||||||
placement="bottom-end"
|
|
||||||
offset={{
|
|
||||||
mainAxis: 4,
|
|
||||||
crossAxis: 55,
|
|
||||||
}}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
<PopoverTrigger
|
||||||
<div
|
render={(
|
||||||
className={cn(
|
<div
|
||||||
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0.5',
|
className={cn(
|
||||||
isFiltering ? 'bg-state-accent-active-alt' : 'hover:bg-state-base-hover',
|
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md p-0.5',
|
||||||
)}
|
isFiltering ? 'bg-state-accent-active-alt' : 'hover:bg-state-base-hover',
|
||||||
>
|
)}
|
||||||
<RiFilter3Line className={cn('h-4 w-4', isFiltering ? 'text-text-accent' : 'text-text-tertiary')} />
|
>
|
||||||
</div>
|
<RiFilter3Line className={cn('h-4 w-4', isFiltering ? 'text-text-accent' : 'text-text-tertiary')} />
|
||||||
</PortalToFollowElemTrigger>
|
</div>
|
||||||
<PortalToFollowElemContent className="z-12">
|
)}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
placement="bottom-end"
|
||||||
|
sideOffset={4}
|
||||||
|
alignOffset={55}
|
||||||
|
popupClassName="border-none bg-transparent shadow-none"
|
||||||
|
>
|
||||||
<div className="flex w-[248px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
<div className="flex w-[248px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||||
<div className="flex flex-col p-1">
|
<div className="flex flex-col p-1">
|
||||||
{
|
{
|
||||||
@ -75,8 +77,8 @@ const Filter: FC<FilterProps> = ({
|
|||||||
<Divider type="horizontal" className="my-0 h-px bg-divider-subtle" />
|
<Divider type="horizontal" className="my-0 h-px bg-divider-subtle" />
|
||||||
<FilterSwitch enabled={isOnlyShowNamedVersions} handleSwitch={handleSwitch} />
|
<FilterSwitch enabled={isOnlyShowNamedVersions} handleSwitch={handleSwitch} />
|
||||||
</div>
|
</div>
|
||||||
</PortalToFollowElemContent>
|
</PopoverContent>
|
||||||
</PortalToFollowElem>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -55,7 +55,7 @@ pnpm -C web lint:fix --prune-suppressions <changed-files>
|
|||||||
|
|
||||||
## z-index strategy
|
## z-index strategy
|
||||||
|
|
||||||
All new overlay primitives in `@langgenius/dify-ui` share a single z-index value:
|
All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index value:
|
||||||
**`z-1002`**, except Toast which stays one layer above at **`z-1003`**.
|
**`z-1002`**, except Toast which stays one layer above at **`z-1003`**.
|
||||||
|
|
||||||
### Why z-[1002]?
|
### Why z-[1002]?
|
||||||
@ -94,7 +94,7 @@ back to `z-9999`.
|
|||||||
|
|
||||||
Once all legacy overlays are removed:
|
Once all legacy overlays are removed:
|
||||||
|
|
||||||
1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui` primitives.
|
1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui/*` primitives.
|
||||||
1. Reduce Toast from `z-1003` to `z-51`.
|
1. Reduce Toast from `z-1003` to `z-51`.
|
||||||
1. Remove this section from the migration guide.
|
1. Remove this section from the migration guide.
|
||||||
|
|
||||||
|
|||||||
@ -64,20 +64,13 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
|
|||||||
export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
|
export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
|
||||||
'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx',
|
'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx',
|
||||||
'app/components/base/chat/chat-with-history/header/operation.tsx',
|
'app/components/base/chat/chat-with-history/header/operation.tsx',
|
||||||
'app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx',
|
|
||||||
'app/components/base/chat/chat-with-history/sidebar/operation.tsx',
|
'app/components/base/chat/chat-with-history/sidebar/operation.tsx',
|
||||||
'app/components/base/chat/chat/citation/popup.tsx',
|
'app/components/base/chat/chat/citation/popup.tsx',
|
||||||
'app/components/base/chat/chat/citation/progress-tooltip.tsx',
|
'app/components/base/chat/chat/citation/progress-tooltip.tsx',
|
||||||
'app/components/base/chat/chat/citation/tooltip.tsx',
|
'app/components/base/chat/chat/citation/tooltip.tsx',
|
||||||
'app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx',
|
|
||||||
'app/components/base/chip/index.tsx',
|
'app/components/base/chip/index.tsx',
|
||||||
'app/components/base/date-and-time-picker/date-picker/index.tsx',
|
'app/components/base/date-and-time-picker/date-picker/index.tsx',
|
||||||
'app/components/base/date-and-time-picker/time-picker/index.tsx',
|
'app/components/base/date-and-time-picker/time-picker/index.tsx',
|
||||||
'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx',
|
|
||||||
'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx',
|
|
||||||
'app/components/base/file-uploader/file-from-link-or-local/index.tsx',
|
|
||||||
'app/components/base/image-uploader/chat-image-uploader.tsx',
|
|
||||||
'app/components/base/image-uploader/text-generation-image-uploader.tsx',
|
|
||||||
'app/components/base/modal/modal.tsx',
|
'app/components/base/modal/modal.tsx',
|
||||||
'app/components/base/prompt-editor/plugins/context-block/component.tsx',
|
'app/components/base/prompt-editor/plugins/context-block/component.tsx',
|
||||||
'app/components/base/prompt-editor/plugins/history-block/component.tsx',
|
'app/components/base/prompt-editor/plugins/history-block/component.tsx',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user