mirror of
https://github.com/langgenius/dify.git
synced 2026-04-28 11:56:55 +08:00
feat(trigger): update subscription list after saving draft
This commit is contained in:
parent
b07e80e6ae
commit
9aec255ee9
@ -9,10 +9,15 @@ export enum SubscriptionListMode {
|
|||||||
SELECTOR = 'selector',
|
SELECTOR = 'selector',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SimpleSubscription = {
|
||||||
|
id: string,
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
type SubscriptionListProps = {
|
type SubscriptionListProps = {
|
||||||
mode?: SubscriptionListMode
|
mode?: SubscriptionListMode
|
||||||
selectedId?: string
|
selectedId?: string
|
||||||
onSelect?: ({ id, name }: { id: string, name: string }) => void
|
onSelect?: (v: SimpleSubscription, callback?: () => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export { SubscriptionSelectorEntry } from './selector-entry'
|
export { SubscriptionSelectorEntry } from './selector-entry'
|
||||||
@ -22,7 +27,7 @@ export const SubscriptionList = withErrorBoundary(({
|
|||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: SubscriptionListProps) => {
|
}: SubscriptionListProps) => {
|
||||||
const { subscriptions, isLoading } = useSubscriptionList()
|
const { isLoading, refetch } = useSubscriptionList()
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-center py-4'>
|
<div className='flex items-center justify-center py-4'>
|
||||||
@ -34,16 +39,13 @@ export const SubscriptionList = withErrorBoundary(({
|
|||||||
if (mode === SubscriptionListMode.SELECTOR) {
|
if (mode === SubscriptionListMode.SELECTOR) {
|
||||||
return (
|
return (
|
||||||
<SubscriptionSelectorView
|
<SubscriptionSelectorView
|
||||||
subscriptions={subscriptions}
|
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={(v) => {
|
||||||
|
onSelect?.(v, refetch)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <SubscriptionListView />
|
||||||
<SubscriptionListView
|
|
||||||
subscriptions={subscriptions}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,22 +1,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import Tooltip from '@/app/components/base/tooltip'
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
|
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CreateButtonType, CreateSubscriptionButton } from './create'
|
import { CreateButtonType, CreateSubscriptionButton } from './create'
|
||||||
import SubscriptionCard from './subscription-card'
|
import SubscriptionCard from './subscription-card'
|
||||||
|
import { useSubscriptionList } from './use-subscription-list'
|
||||||
|
|
||||||
type SubscriptionListViewProps = {
|
type SubscriptionListViewProps = {
|
||||||
subscriptions?: TriggerSubscription[]
|
|
||||||
showTopBorder?: boolean
|
showTopBorder?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
|
export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
|
||||||
subscriptions,
|
|
||||||
showTopBorder = false,
|
showTopBorder = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { subscriptions } = useSubscriptionList()
|
||||||
|
|
||||||
const subscriptionCount = subscriptions?.length || 0
|
const subscriptionCount = subscriptions?.length || 0
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
PortalToFollowElemContent,
|
PortalToFollowElemContent,
|
||||||
PortalToFollowElemTrigger,
|
PortalToFollowElemTrigger,
|
||||||
} from '@/app/components/base/portal-to-follow-elem'
|
} from '@/app/components/base/portal-to-follow-elem'
|
||||||
|
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||||
import { SubscriptionList, SubscriptionListMode } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
import { SubscriptionList, SubscriptionListMode } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react'
|
import { RiArrowDownSLine, RiWebhookLine } from '@remixicon/react'
|
||||||
@ -74,7 +75,7 @@ const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
|
|||||||
|
|
||||||
export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
|
export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
|
||||||
selectedId?: string,
|
selectedId?: string,
|
||||||
onSelect: ({ id, name }: { id: string, name: string }) => void
|
onSelect: (v: SimpleSubscription, callback?: () => void) => void
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
|||||||
@ -8,19 +8,19 @@ import React, { useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CreateButtonType, CreateSubscriptionButton } from './create'
|
import { CreateButtonType, CreateSubscriptionButton } from './create'
|
||||||
import { DeleteConfirm } from './delete-confirm'
|
import { DeleteConfirm } from './delete-confirm'
|
||||||
|
import { useSubscriptionList } from './use-subscription-list'
|
||||||
|
|
||||||
type SubscriptionSelectorProps = {
|
type SubscriptionSelectorProps = {
|
||||||
subscriptions?: TriggerSubscription[]
|
|
||||||
selectedId?: string
|
selectedId?: string
|
||||||
onSelect?: ({ id, name }: { id: string, name: string }) => void
|
onSelect?: ({ id, name }: { id: string, name: string }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
|
export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
|
||||||
subscriptions,
|
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { subscriptions } = useSubscriptionList()
|
||||||
const [deletedSubscription, setDeletedSubscription] = useState<TriggerSubscription | null>(null)
|
const [deletedSubscription, setDeletedSubscription] = useState<TriggerSubscription | null>(null)
|
||||||
const subscriptionCount = subscriptions?.length || 0
|
const subscriptionCount = subscriptions?.length || 0
|
||||||
|
|
||||||
@ -39,47 +39,33 @@ export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
<div className='max-h-[320px] overflow-y-auto'>
|
<div className='max-h-[320px] overflow-y-auto'>
|
||||||
{subscriptionCount > 0 ? (
|
{subscriptions?.map(subscription => (
|
||||||
<>
|
<button
|
||||||
{subscriptions?.map(subscription => (
|
key={subscription.id}
|
||||||
<button
|
className={cn(
|
||||||
key={subscription.id}
|
'group flex w-full items-center justify-between rounded-lg p-1 text-left transition-colors',
|
||||||
className={cn(
|
'hover:bg-state-base-hover has-[.subscription-delete-btn:hover]:!bg-state-destructive-hover',
|
||||||
'group flex w-full items-center justify-between rounded-lg p-1 text-left transition-colors',
|
selectedId === subscription.id && 'bg-state-base-hover',
|
||||||
'hover:bg-state-base-hover has-[.subscription-delete-btn:hover]:!bg-state-destructive-hover',
|
)}
|
||||||
selectedId === subscription.id && 'bg-state-base-hover',
|
onClick={() => onSelect?.(subscription)}
|
||||||
)}
|
>
|
||||||
onClick={() => onSelect?.(subscription)}
|
<div className='flex items-center'>
|
||||||
>
|
{selectedId === subscription.id && (
|
||||||
<div className='flex items-center'>
|
<RiCheckLine className='mr-2 h-4 w-4 shrink-0 text-text-accent' />
|
||||||
{selectedId === subscription.id && (
|
)}
|
||||||
<RiCheckLine className='mr-2 h-4 w-4 shrink-0 text-text-accent' />
|
<RiWebhookLine className={cn('mr-1.5 h-3.5 w-3.5 text-text-secondary', selectedId !== subscription.id && 'ml-6')} />
|
||||||
)}
|
<span className='system-md-regular leading-6 text-text-secondary'>
|
||||||
<RiWebhookLine className={cn('mr-1.5 h-3.5 w-3.5 text-text-secondary', selectedId !== subscription.id && 'ml-6')} />
|
{subscription.name}
|
||||||
<span className='system-md-regular leading-6 text-text-secondary'>
|
</span>
|
||||||
{subscription.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ActionButton onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeletedSubscription(subscription)
|
|
||||||
}} className='subscription-delete-btn hidden shrink-0 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive group-hover:flex'>
|
|
||||||
<RiDeleteBinLine className='size-4' />
|
|
||||||
</ActionButton>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// todo: refactor this
|
|
||||||
<div className='p-2 text-center'>
|
|
||||||
<div className='mb-2 text-sm text-text-tertiary'>
|
|
||||||
{t('pluginTrigger.subscription.empty.description')}
|
|
||||||
</div>
|
</div>
|
||||||
<CreateSubscriptionButton
|
<ActionButton onClick={(e) => {
|
||||||
buttonType={CreateButtonType.FULL_BUTTON}
|
e.stopPropagation()
|
||||||
/>
|
setDeletedSubscription(subscription)
|
||||||
</div>
|
}} className='subscription-delete-btn hidden shrink-0 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive group-hover:flex'>
|
||||||
)}
|
<RiDeleteBinLine className='size-4' />
|
||||||
|
</ActionButton>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{deletedSubscription && (
|
{deletedSubscription && (
|
||||||
<DeleteConfirm
|
<DeleteConfirm
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react'
|
|
||||||
import produce from 'immer'
|
import produce from 'immer'
|
||||||
|
import { useCallback } from 'react'
|
||||||
import { useStoreApi } from 'reactflow'
|
import { useStoreApi } from 'reactflow'
|
||||||
|
import type { SyncCallback } from './use-nodes-sync-draft'
|
||||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||||
import { useNodesReadOnly } from './use-workflow'
|
import { useNodesReadOnly } from './use-workflow'
|
||||||
|
|
||||||
@ -28,12 +29,19 @@ export const useNodeDataUpdate = () => {
|
|||||||
setNodes(newNodes)
|
setNodes(newNodes)
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
const handleNodeDataUpdateWithSyncDraft = useCallback((payload: NodeDataUpdatePayload) => {
|
const handleNodeDataUpdateWithSyncDraft = useCallback((
|
||||||
|
payload: NodeDataUpdatePayload,
|
||||||
|
options?: {
|
||||||
|
sync?: boolean
|
||||||
|
notRefreshWhenSyncError?: boolean
|
||||||
|
callback?: SyncCallback
|
||||||
|
},
|
||||||
|
) => {
|
||||||
if (getNodesReadOnly())
|
if (getNodesReadOnly())
|
||||||
return
|
return
|
||||||
|
|
||||||
handleNodeDataUpdate(payload)
|
handleNodeDataUpdate(payload)
|
||||||
handleSyncWorkflowDraft()
|
handleSyncWorkflowDraft(options?.sync, options?.notRefreshWhenSyncError, options?.callback)
|
||||||
}, [handleSyncWorkflowDraft, handleNodeDataUpdate, getNodesReadOnly])
|
}, [handleSyncWorkflowDraft, handleNodeDataUpdate, getNodesReadOnly])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -7,6 +7,12 @@ import {
|
|||||||
} from './use-workflow'
|
} from './use-workflow'
|
||||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||||
|
|
||||||
|
export type SyncCallback = {
|
||||||
|
onSuccess?: () => void
|
||||||
|
onError?: () => void
|
||||||
|
onSettled?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export const useNodesSyncDraft = () => {
|
export const useNodesSyncDraft = () => {
|
||||||
const { getNodesReadOnly } = useNodesReadOnly()
|
const { getNodesReadOnly } = useNodesReadOnly()
|
||||||
const debouncedSyncWorkflowDraft = useStore(s => s.debouncedSyncWorkflowDraft)
|
const debouncedSyncWorkflowDraft = useStore(s => s.debouncedSyncWorkflowDraft)
|
||||||
@ -16,11 +22,7 @@ export const useNodesSyncDraft = () => {
|
|||||||
const handleSyncWorkflowDraft = useCallback((
|
const handleSyncWorkflowDraft = useCallback((
|
||||||
sync?: boolean,
|
sync?: boolean,
|
||||||
notRefreshWhenSyncError?: boolean,
|
notRefreshWhenSyncError?: boolean,
|
||||||
callback?: {
|
callback?: SyncCallback,
|
||||||
onSuccess?: () => void
|
|
||||||
onError?: () => void
|
|
||||||
onSettled?: () => void
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
if (getNodesReadOnly())
|
if (getNodesReadOnly())
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,3 +1,31 @@
|
|||||||
|
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||||
|
import Tooltip from '@/app/components/base/tooltip'
|
||||||
|
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||||
|
import {
|
||||||
|
WorkflowHistoryEvent,
|
||||||
|
useAvailableBlocks,
|
||||||
|
useNodeDataUpdate,
|
||||||
|
useNodesInteractions,
|
||||||
|
useNodesMetaData,
|
||||||
|
useNodesReadOnly,
|
||||||
|
useToolIcon,
|
||||||
|
useWorkflowHistory,
|
||||||
|
} from '@/app/components/workflow/hooks'
|
||||||
|
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||||
|
import { useStore } from '@/app/components/workflow/store'
|
||||||
|
import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types'
|
||||||
|
import {
|
||||||
|
canRunBySingle,
|
||||||
|
hasErrorHandleNode,
|
||||||
|
hasRetryNode,
|
||||||
|
isSupportCustomRunForm,
|
||||||
|
} from '@/app/components/workflow/utils'
|
||||||
|
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||||
|
import cn from '@/utils/classnames'
|
||||||
|
import {
|
||||||
|
RiCloseLine,
|
||||||
|
RiPlayLargeLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
import type {
|
import type {
|
||||||
FC,
|
FC,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
@ -11,71 +39,44 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
|
||||||
RiCloseLine,
|
|
||||||
RiPlayLargeLine,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { useResizePanel } from '../../hooks/use-resize-panel'
|
||||||
|
import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel'
|
||||||
|
import HelpLink from '../help-link'
|
||||||
import NextStep from '../next-step'
|
import NextStep from '../next-step'
|
||||||
import PanelOperator from '../panel-operator'
|
import PanelOperator from '../panel-operator'
|
||||||
import HelpLink from '../help-link'
|
import RetryOnPanel from '../retry/retry-on-panel'
|
||||||
import {
|
import {
|
||||||
DescriptionInput,
|
DescriptionInput,
|
||||||
TitleInput,
|
TitleInput,
|
||||||
} from '../title-description-input'
|
} from '../title-description-input'
|
||||||
import ErrorHandleOnPanel from '../error-handle/error-handle-on-panel'
|
|
||||||
import RetryOnPanel from '../retry/retry-on-panel'
|
|
||||||
import { useResizePanel } from '../../hooks/use-resize-panel'
|
|
||||||
import cn from '@/utils/classnames'
|
|
||||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
|
||||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
|
||||||
import {
|
|
||||||
WorkflowHistoryEvent,
|
|
||||||
useAvailableBlocks,
|
|
||||||
useNodeDataUpdate,
|
|
||||||
useNodesInteractions,
|
|
||||||
useNodesMetaData,
|
|
||||||
useNodesReadOnly,
|
|
||||||
useToolIcon,
|
|
||||||
useWorkflowHistory,
|
|
||||||
} from '@/app/components/workflow/hooks'
|
|
||||||
import {
|
|
||||||
canRunBySingle,
|
|
||||||
hasErrorHandleNode,
|
|
||||||
hasRetryNode,
|
|
||||||
isSupportCustomRunForm,
|
|
||||||
} from '@/app/components/workflow/utils'
|
|
||||||
import Tooltip from '@/app/components/base/tooltip'
|
|
||||||
import { BlockEnum, type Node, NodeRunningStatus } from '@/app/components/workflow/types'
|
|
||||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
|
||||||
import { useStore } from '@/app/components/workflow/store'
|
|
||||||
import Tab, { TabType } from './tab'
|
import Tab, { TabType } from './tab'
|
||||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
|
||||||
// import AuthMethodSelector from '@/app/components/workflow/nodes/trigger-plugin/components/auth-method-selector'
|
// import AuthMethodSelector from '@/app/components/workflow/nodes/trigger-plugin/components/auth-method-selector'
|
||||||
import LastRun from './last-run'
|
|
||||||
import useLastRun from './last-run/use-last-run'
|
|
||||||
import BeforeRunForm from '../before-run-form'
|
|
||||||
import { debounce } from 'lodash-es'
|
|
||||||
import { useLogs } from '@/app/components/workflow/run/hooks'
|
|
||||||
import PanelWrap from '../before-run-form/panel-wrap'
|
|
||||||
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
|
|
||||||
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
|
||||||
import { FlowType } from '@/types/common'
|
|
||||||
import {
|
import {
|
||||||
|
AuthCategory,
|
||||||
AuthorizedInDataSourceNode,
|
AuthorizedInDataSourceNode,
|
||||||
AuthorizedInNode,
|
AuthorizedInNode,
|
||||||
PluginAuth,
|
PluginAuth,
|
||||||
PluginAuthInDataSourceNode,
|
PluginAuthInDataSourceNode,
|
||||||
} from '@/app/components/plugins/plugin-auth'
|
} from '@/app/components/plugins/plugin-auth'
|
||||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
|
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||||
import { canFindTool } from '@/utils'
|
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||||
|
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||||
|
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
|
||||||
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
|
import type { CustomRunFormProps } from '@/app/components/workflow/nodes/data-source/types'
|
||||||
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
|
import { DataSourceClassification } from '@/app/components/workflow/nodes/data-source/types'
|
||||||
|
import { useLogs } from '@/app/components/workflow/run/hooks'
|
||||||
|
import SpecialResultPanel from '@/app/components/workflow/run/special-result-panel'
|
||||||
import { useModalContext } from '@/context/modal-context'
|
import { useModalContext } from '@/context/modal-context'
|
||||||
import DataSourceBeforeRunForm from '@/app/components/workflow/nodes/data-source/before-run-form'
|
import { FlowType } from '@/types/common'
|
||||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
import { canFindTool } from '@/utils'
|
||||||
|
import { debounce } from 'lodash-es'
|
||||||
|
import BeforeRunForm from '../before-run-form'
|
||||||
|
import PanelWrap from '../before-run-form/panel-wrap'
|
||||||
|
import LastRun from './last-run'
|
||||||
|
import useLastRun from './last-run/use-last-run'
|
||||||
import { TriggerSubscription } from './trigger-subscription'
|
import { TriggerSubscription } from './trigger-subscription'
|
||||||
|
|
||||||
const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => {
|
const getCustomRunForm = (params: CustomRunFormProps): React.JSX.Element => {
|
||||||
@ -311,13 +312,14 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||||||
appendNodeInspectVars,
|
appendNodeInspectVars,
|
||||||
} = useInspectVarsCrud()
|
} = useInspectVarsCrud()
|
||||||
|
|
||||||
const handleSubscriptionChange = useCallback((subscription_id: string) => {
|
const handleSubscriptionChange = useCallback((v: SimpleSubscription, callback?: () => void) => {
|
||||||
handleNodeDataUpdateWithSyncDraft({
|
handleNodeDataUpdateWithSyncDraft(
|
||||||
id,
|
{ id, data: { subscription_id: v.id } },
|
||||||
data: {
|
{
|
||||||
subscription_id,
|
sync: true,
|
||||||
|
callback: { onSettled: callback },
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
}, [handleNodeDataUpdateWithSyncDraft, id])
|
}, [handleNodeDataUpdateWithSyncDraft, id])
|
||||||
|
|
||||||
if (logParams.showSpecialResultPanel) {
|
if (logParams.showSpecialResultPanel) {
|
||||||
@ -497,11 +499,6 @@ const BasePanel: FC<BasePanelProps> = ({
|
|||||||
onAuthorizationItemClick={handleAuthorizationItemClick}
|
onAuthorizationItemClick={handleAuthorizationItemClick}
|
||||||
credentialId={data.credential_id}
|
credentialId={data.credential_id}
|
||||||
/>
|
/>
|
||||||
{/* <NodeAuth
|
|
||||||
data={data}
|
|
||||||
onAuthorizationChange={handleAuthorizationItemClick}
|
|
||||||
onSubscriptionChange={handleSubscriptionChange}
|
|
||||||
/> */}
|
|
||||||
</div>
|
</div>
|
||||||
</PluginAuth>
|
</PluginAuth>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||||
|
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
|
||||||
import { CreateButtonType, CreateSubscriptionButton } from '@/app/components/plugins/plugin-detail-panel/subscription-list/create'
|
import { CreateButtonType, CreateSubscriptionButton } from '@/app/components/plugins/plugin-detail-panel/subscription-list/create'
|
||||||
import { SubscriptionSelectorEntry } from '@/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry'
|
import { SubscriptionSelectorEntry } from '@/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry'
|
||||||
import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/subscription-list/store'
|
import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/subscription-list/store'
|
||||||
@ -11,7 +12,7 @@ import { useEffect } from 'react'
|
|||||||
|
|
||||||
type NodeAuthProps = {
|
type NodeAuthProps = {
|
||||||
data: Node['data']
|
data: Node['data']
|
||||||
onSubscriptionChange?: (id: string, name: string) => void
|
onSubscriptionChange: (v: SimpleSubscription, callback?: () => void) => void
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ export const TriggerSubscription: FC<NodeAuthProps> = ({ data, onSubscriptionCha
|
|||||||
{children}
|
{children}
|
||||||
{subscriptionCount > 0 && <SubscriptionSelectorEntry
|
{subscriptionCount > 0 && <SubscriptionSelectorEntry
|
||||||
selectedId={data.subscription_id}
|
selectedId={data.subscription_id}
|
||||||
onSelect={({ id, name }) => onSubscriptionChange?.(id, name)}
|
onSelect={onSubscriptionChange}
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,185 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
jest.mock('react-i18next')
|
|
||||||
jest.mock('@/service/use-triggers', () => ({
|
|
||||||
useCreateTriggerSubscriptionBuilder: () => ({ mutateAsync: jest.fn().mockResolvedValue({ subscription_builder: { id: 'test-id' } }) }),
|
|
||||||
useUpdateTriggerSubscriptionBuilder: () => ({ mutateAsync: jest.fn() }),
|
|
||||||
useVerifyTriggerSubscriptionBuilder: () => ({ mutateAsync: jest.fn() }),
|
|
||||||
useBuildTriggerSubscription: () => ({ mutateAsync: jest.fn() }),
|
|
||||||
useInvalidateTriggerSubscriptions: () => jest.fn(),
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/base/toast', () => ({
|
|
||||||
useToastContext: () => ({ notify: jest.fn() }),
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
|
||||||
toolCredentialToFormSchemas: jest.fn().mockReturnValue([
|
|
||||||
{
|
|
||||||
name: 'api_key',
|
|
||||||
label: { en_US: 'API Key' },
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
addDefaultValue: jest.fn().mockReturnValue({ api_key: '' }),
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
|
||||||
useLanguage: () => 'en_US',
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => {
|
|
||||||
return function MockForm({ value, onChange, formSchemas }: any) {
|
|
||||||
return (
|
|
||||||
<div data-testid="mock-form">
|
|
||||||
{formSchemas.map((schema: any, index: number) => (
|
|
||||||
<div key={index}>
|
|
||||||
<label htmlFor={schema.name}>{schema.label?.en_US || schema.name}</label>
|
|
||||||
<input
|
|
||||||
id={schema.name}
|
|
||||||
data-testid={`input-${schema.name}`}
|
|
||||||
value={value[schema.name] || ''}
|
|
||||||
onChange={e => onChange({ ...value, [schema.name]: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
import ApiKeyConfigModal from '../api-key-config-modal'
|
|
||||||
|
|
||||||
const mockUseTranslation = useTranslation as jest.MockedFunction<typeof useTranslation>
|
|
||||||
|
|
||||||
const mockTranslation = {
|
|
||||||
t: (key: string, params?: any) => {
|
|
||||||
const translations: Record<string, string> = {
|
|
||||||
'workflow.nodes.triggerPlugin.configureApiKey': 'Configure API Key',
|
|
||||||
'workflow.nodes.triggerPlugin.apiKeyDescription': 'Configure API key credentials for authentication',
|
|
||||||
'workflow.nodes.triggerPlugin.apiKeyConfigured': 'API key configured successfully',
|
|
||||||
'workflow.nodes.triggerPlugin.configurationFailed': 'Configuration failed',
|
|
||||||
'common.operation.cancel': 'Cancel',
|
|
||||||
'common.operation.save': 'Save',
|
|
||||||
'common.errorMsg.fieldRequired': `${params?.field} is required`,
|
|
||||||
}
|
|
||||||
return translations[key] || key
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockProvider = {
|
|
||||||
plugin_id: 'test-plugin',
|
|
||||||
name: 'test-provider',
|
|
||||||
author: 'test',
|
|
||||||
label: { en_US: 'Test Provider' },
|
|
||||||
description: { en_US: 'Test Description' },
|
|
||||||
icon: 'test-icon.svg',
|
|
||||||
icon_dark: null,
|
|
||||||
tags: ['test'],
|
|
||||||
plugin_unique_identifier: 'test:1.0.0',
|
|
||||||
credentials_schema: [
|
|
||||||
{
|
|
||||||
type: 'secret-input' as const,
|
|
||||||
name: 'api_key',
|
|
||||||
required: true,
|
|
||||||
label: { en_US: 'API Key' },
|
|
||||||
scope: null,
|
|
||||||
default: null,
|
|
||||||
options: null,
|
|
||||||
help: null,
|
|
||||||
url: null,
|
|
||||||
placeholder: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
oauth_client_schema: [],
|
|
||||||
subscription_schema: {
|
|
||||||
parameters_schema: [],
|
|
||||||
properties_schema: [],
|
|
||||||
},
|
|
||||||
triggers: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockUseTranslation.mockReturnValue(mockTranslation as any)
|
|
||||||
jest.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ApiKeyConfigModal', () => {
|
|
||||||
const mockProps = {
|
|
||||||
provider: mockProvider,
|
|
||||||
onCancel: jest.fn(),
|
|
||||||
onSuccess: jest.fn(),
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Rendering', () => {
|
|
||||||
it('should render modal with correct title and description', () => {
|
|
||||||
render(<ApiKeyConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
expect(screen.getByText('Configure API Key')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Configure API key credentials for authentication')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render form when credential schema is loaded', async () => {
|
|
||||||
render(<ApiKeyConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render form fields with correct labels', async () => {
|
|
||||||
render(<ApiKeyConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText('API Key')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render cancel and save buttons', async () => {
|
|
||||||
render(<ApiKeyConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Form Interaction', () => {
|
|
||||||
it('should update form values on input change', async () => {
|
|
||||||
render(<ApiKeyConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const apiKeyInput = screen.getByTestId('input-api_key')
|
|
||||||
fireEvent.change(apiKeyInput, { target: { value: 'test-api-key' } })
|
|
||||||
expect(apiKeyInput).toHaveValue('test-api-key')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call onCancel when cancel button is clicked', async () => {
|
|
||||||
render(<ApiKeyConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
||||||
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Save Process', () => {
|
|
||||||
it('should proceed with save when required fields are filled', async () => {
|
|
||||||
render(<ApiKeyConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const apiKeyInput = screen.getByTestId('input-api_key')
|
|
||||||
fireEvent.change(apiKeyInput, { target: { value: 'valid-api-key' } })
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockProps.onSuccess).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
jest.mock('react-i18next')
|
|
||||||
jest.mock('@/service/use-triggers', () => ({
|
|
||||||
useInitiateTriggerOAuth: () => ({ mutateAsync: jest.fn() }),
|
|
||||||
useInvalidateTriggerSubscriptions: () => jest.fn(),
|
|
||||||
useTriggerOAuthConfig: () => ({ data: null }),
|
|
||||||
}))
|
|
||||||
jest.mock('@/hooks/use-oauth', () => ({
|
|
||||||
openOAuthPopup: jest.fn(),
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/base/toast', () => ({
|
|
||||||
useToastContext: () => ({ notify: jest.fn() }),
|
|
||||||
}))
|
|
||||||
jest.mock('../api-key-config-modal', () => {
|
|
||||||
return function MockApiKeyConfigModal({ onCancel }: any) {
|
|
||||||
return (
|
|
||||||
<div data-testid="api-key-modal">
|
|
||||||
<button onClick={onCancel}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
jest.mock('../oauth-client-config-modal', () => {
|
|
||||||
return function MockOAuthClientConfigModal({ onCancel }: any) {
|
|
||||||
return (
|
|
||||||
<div data-testid="oauth-client-modal">
|
|
||||||
<button onClick={onCancel}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
import AuthMethodSelector from '../auth-method-selector'
|
|
||||||
|
|
||||||
const mockUseTranslation = useTranslation as jest.MockedFunction<typeof useTranslation>
|
|
||||||
|
|
||||||
const mockTranslation = {
|
|
||||||
t: (key: string) => {
|
|
||||||
const translations: Record<string, string> = {
|
|
||||||
'workflow.nodes.triggerPlugin.or': 'OR',
|
|
||||||
'workflow.nodes.triggerPlugin.useOAuth': 'Use OAuth',
|
|
||||||
'workflow.nodes.triggerPlugin.useApiKey': 'Use API Key',
|
|
||||||
}
|
|
||||||
return translations[key] || key
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockProvider = {
|
|
||||||
plugin_id: 'test-plugin',
|
|
||||||
name: 'test-provider',
|
|
||||||
author: 'test',
|
|
||||||
label: { en_US: 'Test Provider', zh_Hans: '测试提供者' },
|
|
||||||
description: { en_US: 'Test Description', zh_Hans: '测试描述' },
|
|
||||||
icon: 'test-icon.svg',
|
|
||||||
icon_dark: null,
|
|
||||||
tags: ['test'],
|
|
||||||
plugin_unique_identifier: 'test:1.0.0',
|
|
||||||
credentials_schema: [
|
|
||||||
{
|
|
||||||
type: 'secret-input' as const,
|
|
||||||
name: 'api_key',
|
|
||||||
required: true,
|
|
||||||
label: { en_US: 'API Key', zh_Hans: 'API密钥' },
|
|
||||||
scope: null,
|
|
||||||
default: null,
|
|
||||||
options: null,
|
|
||||||
help: null,
|
|
||||||
url: null,
|
|
||||||
placeholder: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
oauth_client_schema: [
|
|
||||||
{
|
|
||||||
type: 'secret-input' as const,
|
|
||||||
name: 'client_id',
|
|
||||||
required: true,
|
|
||||||
label: { en_US: 'Client ID', zh_Hans: '客户端ID' },
|
|
||||||
scope: null,
|
|
||||||
default: null,
|
|
||||||
options: null,
|
|
||||||
help: null,
|
|
||||||
url: null,
|
|
||||||
placeholder: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subscription_schema: {
|
|
||||||
parameters_schema: [],
|
|
||||||
properties_schema: [],
|
|
||||||
},
|
|
||||||
triggers: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockUseTranslation.mockReturnValue(mockTranslation as any)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('AuthMethodSelector', () => {
|
|
||||||
describe('Rendering', () => {
|
|
||||||
it('should not render when no supported methods are available', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<AuthMethodSelector
|
|
||||||
provider={mockProvider}
|
|
||||||
supportedMethods={[]}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(container.firstChild).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render OAuth button when oauth is supported', () => {
|
|
||||||
render(
|
|
||||||
<AuthMethodSelector
|
|
||||||
provider={mockProvider}
|
|
||||||
supportedMethods={['oauth']}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Use OAuth' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render API Key button when api_key is supported', () => {
|
|
||||||
render(
|
|
||||||
<AuthMethodSelector
|
|
||||||
provider={mockProvider}
|
|
||||||
supportedMethods={['api_key']}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Use API Key' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render both buttons and OR divider when both methods are supported', () => {
|
|
||||||
render(
|
|
||||||
<AuthMethodSelector
|
|
||||||
provider={mockProvider}
|
|
||||||
supportedMethods={['oauth', 'api_key']}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Use OAuth' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: 'Use API Key' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('OR')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Modal Interactions', () => {
|
|
||||||
it('should open API Key modal when API Key button is clicked', () => {
|
|
||||||
render(
|
|
||||||
<AuthMethodSelector
|
|
||||||
provider={mockProvider}
|
|
||||||
supportedMethods={['api_key']}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Use API Key' }))
|
|
||||||
expect(screen.getByTestId('api-key-modal')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should close API Key modal when cancel is clicked', () => {
|
|
||||||
render(
|
|
||||||
<AuthMethodSelector
|
|
||||||
provider={mockProvider}
|
|
||||||
supportedMethods={['api_key']}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Use API Key' }))
|
|
||||||
expect(screen.getByTestId('api-key-modal')).toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Cancel'))
|
|
||||||
expect(screen.queryByTestId('api-key-modal')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should open OAuth client config modal when OAuth settings button is clicked', () => {
|
|
||||||
render(
|
|
||||||
<AuthMethodSelector
|
|
||||||
provider={mockProvider}
|
|
||||||
supportedMethods={['oauth']}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
const settingsButtons = screen.getAllByRole('button')
|
|
||||||
const settingsButton = settingsButtons.find(button =>
|
|
||||||
button.querySelector('svg') && !button.textContent?.includes('Use OAuth'),
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(settingsButton!)
|
|
||||||
expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
jest.mock('react-i18next')
|
|
||||||
jest.mock('@/service/use-triggers', () => ({
|
|
||||||
useConfigureTriggerOAuth: () => ({ mutateAsync: jest.fn() }),
|
|
||||||
useInvalidateTriggerOAuthConfig: () => jest.fn(),
|
|
||||||
useTriggerOAuthConfig: () => ({ data: null, isLoading: false }),
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/base/toast', () => ({
|
|
||||||
useToastContext: () => ({ notify: jest.fn() }),
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
|
||||||
toolCredentialToFormSchemas: jest.fn().mockReturnValue([
|
|
||||||
{
|
|
||||||
name: 'client_id',
|
|
||||||
label: { en_US: 'Client ID' },
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'client_secret',
|
|
||||||
label: { en_US: 'Client Secret' },
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
addDefaultValue: jest.fn().mockReturnValue({ client_id: '', client_secret: '' }),
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
|
||||||
useLanguage: () => 'en_US',
|
|
||||||
}))
|
|
||||||
jest.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => {
|
|
||||||
return function MockForm({ value, onChange, formSchemas }: any) {
|
|
||||||
return (
|
|
||||||
<div data-testid="mock-form">
|
|
||||||
{formSchemas.map((schema: any, index: number) => (
|
|
||||||
<div key={index}>
|
|
||||||
<label htmlFor={schema.name}>{schema.label?.en_US || schema.name}</label>
|
|
||||||
<input
|
|
||||||
id={schema.name}
|
|
||||||
data-testid={`input-${schema.name}`}
|
|
||||||
value={value[schema.name] || ''}
|
|
||||||
onChange={e => onChange({ ...value, [schema.name]: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
import OAuthClientConfigModal from '../oauth-client-config-modal'
|
|
||||||
|
|
||||||
const mockUseTranslation = useTranslation as jest.MockedFunction<typeof useTranslation>
|
|
||||||
|
|
||||||
const mockTranslation = {
|
|
||||||
t: (key: string, params?: any) => {
|
|
||||||
const translations: Record<string, string> = {
|
|
||||||
'workflow.nodes.triggerPlugin.configureOAuthClient': 'Configure OAuth Client',
|
|
||||||
'workflow.nodes.triggerPlugin.oauthClientDescription': 'Configure OAuth client credentials to enable authentication',
|
|
||||||
'workflow.nodes.triggerPlugin.oauthClientSaved': 'OAuth client configuration saved successfully',
|
|
||||||
'workflow.nodes.triggerPlugin.configurationFailed': 'Configuration failed',
|
|
||||||
'common.operation.cancel': 'Cancel',
|
|
||||||
'common.operation.save': 'Save',
|
|
||||||
'common.errorMsg.fieldRequired': `${params?.field} is required`,
|
|
||||||
}
|
|
||||||
return translations[key] || key
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockProvider = {
|
|
||||||
plugin_id: 'test-plugin',
|
|
||||||
name: 'test-provider',
|
|
||||||
author: 'test',
|
|
||||||
label: { en_US: 'Test Provider' },
|
|
||||||
description: { en_US: 'Test Description' },
|
|
||||||
icon: 'test-icon.svg',
|
|
||||||
icon_dark: null,
|
|
||||||
tags: ['test'],
|
|
||||||
plugin_unique_identifier: 'test:1.0.0',
|
|
||||||
credentials_schema: [],
|
|
||||||
oauth_client_schema: [
|
|
||||||
{
|
|
||||||
type: 'secret-input' as const,
|
|
||||||
name: 'client_id',
|
|
||||||
required: true,
|
|
||||||
label: { en_US: 'Client ID' },
|
|
||||||
scope: null,
|
|
||||||
default: null,
|
|
||||||
options: null,
|
|
||||||
help: null,
|
|
||||||
url: null,
|
|
||||||
placeholder: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'secret-input' as const,
|
|
||||||
name: 'client_secret',
|
|
||||||
required: true,
|
|
||||||
label: { en_US: 'Client Secret' },
|
|
||||||
scope: null,
|
|
||||||
default: null,
|
|
||||||
options: null,
|
|
||||||
help: null,
|
|
||||||
url: null,
|
|
||||||
placeholder: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subscription_schema: {
|
|
||||||
parameters_schema: [],
|
|
||||||
properties_schema: [],
|
|
||||||
},
|
|
||||||
triggers: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockUseTranslation.mockReturnValue(mockTranslation as any)
|
|
||||||
jest.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('OAuthClientConfigModal', () => {
|
|
||||||
const mockProps = {
|
|
||||||
provider: mockProvider,
|
|
||||||
onCancel: jest.fn(),
|
|
||||||
onSuccess: jest.fn(),
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Rendering', () => {
|
|
||||||
it('should render modal with correct title and description', () => {
|
|
||||||
render(<OAuthClientConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
expect(screen.getByText('Configure OAuth Client')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Configure OAuth client credentials to enable authentication')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render form when schema is loaded', async () => {
|
|
||||||
render(<OAuthClientConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render form fields with correct labels', async () => {
|
|
||||||
render(<OAuthClientConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText('Client ID')).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText('Client Secret')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render cancel and save buttons', async () => {
|
|
||||||
render(<OAuthClientConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Form Interaction', () => {
|
|
||||||
it('should update form values on input change', async () => {
|
|
||||||
render(<OAuthClientConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const clientIdInput = screen.getByTestId('input-client_id')
|
|
||||||
fireEvent.change(clientIdInput, { target: { value: 'test-client-id' } })
|
|
||||||
expect(clientIdInput).toHaveValue('test-client-id')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call onCancel when cancel button is clicked', async () => {
|
|
||||||
render(<OAuthClientConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
||||||
expect(mockProps.onCancel).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Save Process', () => {
|
|
||||||
it('should proceed with save when required fields are filled', async () => {
|
|
||||||
render(<OAuthClientConfigModal {...mockProps} />)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const clientIdInput = screen.getByTestId('input-client_id')
|
|
||||||
const clientSecretInput = screen.getByTestId('input-client_secret')
|
|
||||||
|
|
||||||
fireEvent.change(clientIdInput, { target: { value: 'valid-client-id' } })
|
|
||||||
fireEvent.change(clientSecretInput, { target: { value: 'valid-client-secret' } })
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockProps.onSuccess).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import type { FC } from 'react'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
|
||||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
|
||||||
import Drawer from '@/app/components/base/drawer-plus'
|
|
||||||
import Button from '@/app/components/base/button'
|
|
||||||
import Toast from '@/app/components/base/toast'
|
|
||||||
import Loading from '@/app/components/base/loading'
|
|
||||||
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
|
||||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
|
||||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
|
||||||
import { useInvalidateTriggerSubscriptions } from '@/service/use-triggers'
|
|
||||||
import { useToastContext } from '@/app/components/base/toast'
|
|
||||||
import { findMissingRequiredField, sanitizeFormValues } from '../utils/form-helpers'
|
|
||||||
import { useTriggerAuthFlow } from '../hooks/use-trigger-auth-flow'
|
|
||||||
import ParametersForm from './parameters-form'
|
|
||||||
|
|
||||||
type ApiKeyConfigModalProps = {
|
|
||||||
provider: TriggerWithProvider
|
|
||||||
onCancel: () => void
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ApiKeyConfigModal: FC<ApiKeyConfigModalProps> = ({
|
|
||||||
provider,
|
|
||||||
onCancel,
|
|
||||||
onSuccess,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { notify } = useToastContext()
|
|
||||||
const language = useLanguage()
|
|
||||||
const invalidateSubscriptions = useInvalidateTriggerSubscriptions()
|
|
||||||
|
|
||||||
const [credentialSchema, setCredentialSchema] = useState<any[]>([])
|
|
||||||
const [credentials, setCredentials] = useState<Record<string, any>>({})
|
|
||||||
const [parameters, setParameters] = useState<Record<string, any>>({})
|
|
||||||
const [properties, setProperties] = useState<Record<string, any>>({})
|
|
||||||
const [subscriptionName, setSubscriptionName] = useState<string>('')
|
|
||||||
|
|
||||||
const {
|
|
||||||
step,
|
|
||||||
builderId,
|
|
||||||
isLoading,
|
|
||||||
startAuth,
|
|
||||||
verifyAuth,
|
|
||||||
completeConfig,
|
|
||||||
reset,
|
|
||||||
} = useTriggerAuthFlow(provider)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (provider.credentials_schema) {
|
|
||||||
const schemas = toolCredentialToFormSchemas(provider.credentials_schema as any)
|
|
||||||
setCredentialSchema(schemas)
|
|
||||||
const defaultCredentials = addDefaultValue({}, schemas)
|
|
||||||
setCredentials(sanitizeFormValues(defaultCredentials))
|
|
||||||
}
|
|
||||||
}, [provider.credentials_schema])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
startAuth().catch((err) => {
|
|
||||||
notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.failedToStart', { error: err.message }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
}, []) // Remove dependencies to run only once on mount
|
|
||||||
|
|
||||||
const handleCredentialsSubmit = async () => {
|
|
||||||
const requiredFields = credentialSchema
|
|
||||||
.filter(field => field.required)
|
|
||||||
.map(field => ({
|
|
||||||
name: field.name,
|
|
||||||
label: field.label[language] || field.label.en_US,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const missingField = findMissingRequiredField(credentials, requiredFields)
|
|
||||||
if (missingField) {
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('common.errorMsg.fieldRequired', {
|
|
||||||
field: missingField.label,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await verifyAuth(credentials)
|
|
||||||
notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.credentialsVerified'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch (err: any) {
|
|
||||||
notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.credentialVerificationFailed', {
|
|
||||||
error: err.message,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFinalSubmit = async () => {
|
|
||||||
if (!subscriptionName.trim()) {
|
|
||||||
notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.subscriptionNameRequired'),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await completeConfig(parameters, properties, subscriptionName)
|
|
||||||
|
|
||||||
invalidateSubscriptions(provider.name)
|
|
||||||
notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.configurationComplete'),
|
|
||||||
})
|
|
||||||
onSuccess()
|
|
||||||
}
|
|
||||||
catch (err: any) {
|
|
||||||
notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.configurationFailed', { error: err.message }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTitle = () => {
|
|
||||||
switch (step) {
|
|
||||||
case 'auth':
|
|
||||||
return t('workflow.nodes.triggerPlugin.configureApiKey')
|
|
||||||
case 'params':
|
|
||||||
return t('workflow.nodes.triggerPlugin.configureParameters')
|
|
||||||
case 'complete':
|
|
||||||
return t('workflow.nodes.triggerPlugin.configurationComplete')
|
|
||||||
default:
|
|
||||||
return t('workflow.nodes.triggerPlugin.configureApiKey')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDescription = () => {
|
|
||||||
switch (step) {
|
|
||||||
case 'auth':
|
|
||||||
return t('workflow.nodes.triggerPlugin.apiKeyDescription')
|
|
||||||
case 'params':
|
|
||||||
return t('workflow.nodes.triggerPlugin.parametersDescription')
|
|
||||||
case 'complete':
|
|
||||||
return t('workflow.nodes.triggerPlugin.configurationCompleteDescription')
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (credentialSchema.length === 0 && step === 'auth')
|
|
||||||
return <Loading type='app' />
|
|
||||||
|
|
||||||
switch (step) {
|
|
||||||
case 'auth':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form
|
|
||||||
value={credentials}
|
|
||||||
onChange={setCredentials}
|
|
||||||
formSchemas={credentialSchema}
|
|
||||||
isEditMode={true}
|
|
||||||
showOnVariableMap={{}}
|
|
||||||
validating={false}
|
|
||||||
inputClassName='!bg-components-input-bg-normal'
|
|
||||||
fieldMoreInfo={item => item.url ? (
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='inline-flex items-center text-xs text-text-accent'
|
|
||||||
>
|
|
||||||
{t('tools.howToGet')}
|
|
||||||
<LinkExternal02 className='ml-1 h-3 w-3' />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
<div className='mt-4 flex justify-end space-x-2'>
|
|
||||||
<Button onClick={onCancel}>
|
|
||||||
{t('common.operation.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isLoading}
|
|
||||||
variant='primary'
|
|
||||||
onClick={handleCredentialsSubmit}
|
|
||||||
>
|
|
||||||
{t('workflow.nodes.triggerPlugin.verifyAndContinue')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'params':
|
|
||||||
return (
|
|
||||||
<ParametersForm
|
|
||||||
provider={provider}
|
|
||||||
builderId={builderId}
|
|
||||||
parametersValue={parameters}
|
|
||||||
propertiesValue={properties}
|
|
||||||
subscriptionName={subscriptionName}
|
|
||||||
onParametersChange={setParameters}
|
|
||||||
onPropertiesChange={setProperties}
|
|
||||||
onSubscriptionNameChange={setSubscriptionName}
|
|
||||||
onSubmit={handleFinalSubmit}
|
|
||||||
onCancel={onCancel}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'complete':
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8">
|
|
||||||
<div className="bg-background-success-emphasis mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
||||||
<svg className="h-6 w-6 text-text-success" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="mb-4 text-center text-text-primary">
|
|
||||||
{t('workflow.nodes.triggerPlugin.configurationCompleteMessage')}
|
|
||||||
</p>
|
|
||||||
<Button variant="primary" onClick={onSuccess}>
|
|
||||||
{t('common.operation.done')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
isShow
|
|
||||||
onHide={onCancel}
|
|
||||||
title={getTitle()}
|
|
||||||
titleDescription={getDescription()}
|
|
||||||
panelClassName='mt-[64px] mb-2 !w-[420px] border-components-panel-border'
|
|
||||||
maxWidthClassName='!max-w-[420px]'
|
|
||||||
height='calc(100vh - 64px)'
|
|
||||||
contentClassName='!bg-components-panel-bg'
|
|
||||||
headerClassName='!border-b-divider-subtle'
|
|
||||||
body={
|
|
||||||
<div className='h-full px-6 py-3'>
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
isShowMask={true}
|
|
||||||
clickOutsideNotOpen={false}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(ApiKeyConfigModal)
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import type { FC } from 'react'
|
|
||||||
import { useCallback, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { RiEqualizer2Line } from '@remixicon/react'
|
|
||||||
import Button from '@/app/components/base/button'
|
|
||||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
|
||||||
import {
|
|
||||||
useInitiateTriggerOAuth,
|
|
||||||
useInvalidateTriggerSubscriptions,
|
|
||||||
useTriggerOAuthConfig,
|
|
||||||
} from '@/service/use-triggers'
|
|
||||||
import { useToastContext } from '@/app/components/base/toast'
|
|
||||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
|
||||||
import ApiKeyConfigModal from './api-key-config-modal'
|
|
||||||
import OAuthClientConfigModal from './oauth-client-config-modal'
|
|
||||||
|
|
||||||
type AuthMethodSelectorProps = {
|
|
||||||
provider: TriggerWithProvider
|
|
||||||
supportedMethods: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthMethodSelector: FC<AuthMethodSelectorProps> = ({
|
|
||||||
provider,
|
|
||||||
supportedMethods,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { notify } = useToastContext()
|
|
||||||
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
|
|
||||||
const [showOAuthClientModal, setShowOAuthClientModal] = useState(false)
|
|
||||||
const initiateTriggerOAuth = useInitiateTriggerOAuth()
|
|
||||||
const invalidateSubscriptions = useInvalidateTriggerSubscriptions()
|
|
||||||
|
|
||||||
const { data: oauthConfig } = useTriggerOAuthConfig(provider.name, supportedMethods.includes('oauth'))
|
|
||||||
|
|
||||||
const handleOAuthAuth = useCallback(async () => {
|
|
||||||
// Check if OAuth client is configured
|
|
||||||
if (!oauthConfig?.custom_configured || !oauthConfig?.custom_enabled) {
|
|
||||||
// Need to configure OAuth client first
|
|
||||||
setShowOAuthClientModal(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await initiateTriggerOAuth.mutateAsync(provider.name)
|
|
||||||
if (response.authorization_url) {
|
|
||||||
openOAuthPopup(response.authorization_url, (callbackData) => {
|
|
||||||
invalidateSubscriptions(provider.name)
|
|
||||||
|
|
||||||
if (callbackData?.success === false) {
|
|
||||||
notify({
|
|
||||||
type: 'error',
|
|
||||||
message: callbackData.errorDescription || callbackData.error || t('workflow.nodes.triggerPlugin.authenticationFailed'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else if (callbackData?.subscriptionId) {
|
|
||||||
notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.authenticationSuccess'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.oauthConfigFailed', { error: error.message }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [provider.name, initiateTriggerOAuth, invalidateSubscriptions, notify, oauthConfig])
|
|
||||||
|
|
||||||
const handleApiKeyAuth = useCallback(() => {
|
|
||||||
setShowApiKeyModal(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!supportedMethods.includes('oauth') && !supportedMethods.includes('api_key'))
|
|
||||||
return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 pb-2">
|
|
||||||
<div className="flex w-full items-center">
|
|
||||||
{/* OAuth Button Group */}
|
|
||||||
{supportedMethods.includes('oauth') && (
|
|
||||||
<div className="flex flex-1">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="medium"
|
|
||||||
onClick={handleOAuthAuth}
|
|
||||||
className="flex-1 rounded-r-none"
|
|
||||||
>
|
|
||||||
{t('workflow.nodes.triggerPlugin.useOAuth')}
|
|
||||||
</Button>
|
|
||||||
<div className="h-4 w-px bg-text-primary-on-surface opacity-15" />
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="medium"
|
|
||||||
className="min-w-0 rounded-l-none px-2"
|
|
||||||
onClick={() => setShowOAuthClientModal(true)}
|
|
||||||
>
|
|
||||||
<RiEqualizer2Line className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Divider with OR */}
|
|
||||||
{supportedMethods.includes('oauth') && supportedMethods.includes('api_key') && (
|
|
||||||
<div className="flex h-8 flex-col items-center justify-center px-1">
|
|
||||||
<div className="h-2 w-px bg-divider-subtle" />
|
|
||||||
<span className="px-1 text-xs font-medium text-text-tertiary">{t('workflow.nodes.triggerPlugin.or')}</span>
|
|
||||||
<div className="h-2 w-px bg-divider-subtle" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* API Key Button */}
|
|
||||||
{supportedMethods.includes('api_key') && (
|
|
||||||
<div className="flex flex-1">
|
|
||||||
<Button
|
|
||||||
variant="secondary-accent"
|
|
||||||
size="medium"
|
|
||||||
onClick={handleApiKeyAuth}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{t('workflow.nodes.triggerPlugin.useApiKey')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Key Configuration Modal */}
|
|
||||||
{showApiKeyModal && (
|
|
||||||
<ApiKeyConfigModal
|
|
||||||
provider={provider}
|
|
||||||
onCancel={() => setShowApiKeyModal(false)}
|
|
||||||
onSuccess={() => {
|
|
||||||
setShowApiKeyModal(false)
|
|
||||||
invalidateSubscriptions(provider.name)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* OAuth Client Configuration Modal */}
|
|
||||||
{showOAuthClientModal && (
|
|
||||||
<OAuthClientConfigModal
|
|
||||||
provider={provider}
|
|
||||||
onCancel={() => setShowOAuthClientModal(false)}
|
|
||||||
onSuccess={() => {
|
|
||||||
setShowOAuthClientModal(false)
|
|
||||||
// After OAuth client configuration, proceed with OAuth auth
|
|
||||||
handleOAuthAuth()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AuthMethodSelector
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import type { FC } from 'react'
|
|
||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
|
||||||
import type { TriggerOAuthClientParams, TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
|
||||||
import Drawer from '@/app/components/base/drawer-plus'
|
|
||||||
import Button from '@/app/components/base/button'
|
|
||||||
import Toast from '@/app/components/base/toast'
|
|
||||||
import Loading from '@/app/components/base/loading'
|
|
||||||
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
|
||||||
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
|
|
||||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
|
||||||
import {
|
|
||||||
useConfigureTriggerOAuth,
|
|
||||||
useInvalidateTriggerOAuthConfig,
|
|
||||||
useTriggerOAuthConfig,
|
|
||||||
} from '@/service/use-triggers'
|
|
||||||
import { useToastContext } from '@/app/components/base/toast'
|
|
||||||
import { findMissingRequiredField, sanitizeFormValues } from '../utils/form-helpers'
|
|
||||||
|
|
||||||
// Type-safe conversion function for dynamic OAuth client parameters
|
|
||||||
const convertToOAuthClientParams = (credentials: Record<string, any>): TriggerOAuthClientParams => {
|
|
||||||
// Use utility function for consistent data sanitization
|
|
||||||
const sanitizedCredentials = sanitizeFormValues(credentials)
|
|
||||||
|
|
||||||
// Create base params with required fields
|
|
||||||
const baseParams: TriggerOAuthClientParams = {
|
|
||||||
client_id: sanitizedCredentials.client_id || '',
|
|
||||||
client_secret: sanitizedCredentials.client_secret || '',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add optional fields if they exist
|
|
||||||
if (sanitizedCredentials.authorization_url)
|
|
||||||
baseParams.authorization_url = sanitizedCredentials.authorization_url
|
|
||||||
if (sanitizedCredentials.token_url)
|
|
||||||
baseParams.token_url = sanitizedCredentials.token_url
|
|
||||||
if (sanitizedCredentials.scope)
|
|
||||||
baseParams.scope = sanitizedCredentials.scope
|
|
||||||
|
|
||||||
return baseParams
|
|
||||||
}
|
|
||||||
|
|
||||||
type OAuthClientConfigModalProps = {
|
|
||||||
provider: TriggerWithProvider
|
|
||||||
onCancel: () => void
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const OAuthClientConfigModal: FC<OAuthClientConfigModalProps> = ({
|
|
||||||
provider,
|
|
||||||
onCancel,
|
|
||||||
onSuccess,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { notify } = useToastContext()
|
|
||||||
const language = useLanguage()
|
|
||||||
const [credentialSchema, setCredentialSchema] = useState<any[]>([])
|
|
||||||
const [tempCredential, setTempCredential] = useState<Record<string, any>>({})
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const { data: oauthConfig, isLoading: isLoadingConfig } = useTriggerOAuthConfig(provider.name)
|
|
||||||
const configureTriggerOAuth = useConfigureTriggerOAuth()
|
|
||||||
const invalidateOAuthConfig = useInvalidateTriggerOAuthConfig()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (provider.oauth_client_schema) {
|
|
||||||
const schemas = toolCredentialToFormSchemas(provider.oauth_client_schema as any)
|
|
||||||
setCredentialSchema(schemas)
|
|
||||||
|
|
||||||
// Load existing configuration if available, ensure no null values
|
|
||||||
const existingParams = oauthConfig?.params || {}
|
|
||||||
const defaultCredentials = addDefaultValue(existingParams, schemas)
|
|
||||||
|
|
||||||
// Use utility function for consistent data sanitization
|
|
||||||
setTempCredential(sanitizeFormValues(defaultCredentials))
|
|
||||||
}
|
|
||||||
}, [provider.oauth_client_schema, oauthConfig])
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
// Validate required fields using utility function
|
|
||||||
const requiredFields = credentialSchema
|
|
||||||
.filter(field => field.required)
|
|
||||||
.map(field => ({
|
|
||||||
name: field.name,
|
|
||||||
label: field.label[language] || field.label.en_US,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const missingField = findMissingRequiredField(tempCredential, requiredFields)
|
|
||||||
if (missingField) {
|
|
||||||
Toast.notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('common.errorMsg.fieldRequired', {
|
|
||||||
field: missingField.label,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await configureTriggerOAuth.mutateAsync({
|
|
||||||
provider: provider.name,
|
|
||||||
client_params: convertToOAuthClientParams(tempCredential),
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Invalidate cache
|
|
||||||
invalidateOAuthConfig(provider.name)
|
|
||||||
|
|
||||||
notify({
|
|
||||||
type: 'success',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.oauthClientSaved'),
|
|
||||||
})
|
|
||||||
onSuccess()
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
notify({
|
|
||||||
type: 'error',
|
|
||||||
message: t('workflow.nodes.triggerPlugin.configurationFailed', { error: error.message }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
isShow
|
|
||||||
onHide={onCancel}
|
|
||||||
title={t('workflow.nodes.triggerPlugin.configureOAuthClient')}
|
|
||||||
titleDescription={t('workflow.nodes.triggerPlugin.oauthClientDescription')}
|
|
||||||
panelClassName='mt-[64px] mb-2 !w-[420px] border-components-panel-border'
|
|
||||||
maxWidthClassName='!max-w-[420px]'
|
|
||||||
height='calc(100vh - 64px)'
|
|
||||||
contentClassName='!bg-components-panel-bg'
|
|
||||||
headerClassName='!border-b-divider-subtle'
|
|
||||||
body={
|
|
||||||
<div className='h-full px-6 py-3'>
|
|
||||||
{isLoadingConfig || credentialSchema.length === 0 ? (
|
|
||||||
<Loading type='app' />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Form
|
|
||||||
value={tempCredential}
|
|
||||||
onChange={(value) => {
|
|
||||||
// Use utility function for consistent data sanitization
|
|
||||||
setTempCredential(sanitizeFormValues(value))
|
|
||||||
}}
|
|
||||||
formSchemas={credentialSchema}
|
|
||||||
isEditMode={true}
|
|
||||||
showOnVariableMap={{}}
|
|
||||||
validating={false}
|
|
||||||
inputClassName='!bg-components-input-bg-normal'
|
|
||||||
fieldMoreInfo={item => item.url ? (
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
className='inline-flex items-center text-xs text-text-accent'
|
|
||||||
>
|
|
||||||
{t('tools.howToGet')}
|
|
||||||
<LinkExternal02 className='ml-1 h-3 w-3' />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
<div className='mt-4 flex justify-end space-x-2'>
|
|
||||||
<Button onClick={onCancel}>
|
|
||||||
{t('common.operation.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isLoading}
|
|
||||||
variant='primary'
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{t('common.operation.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
isShowMask={true}
|
|
||||||
clickOutsideNotOpen={false}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(OAuthClientConfigModal)
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import type { FC } from 'react'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
|
||||||
import type { Event } from '@/app/components/tools/types'
|
|
||||||
import { toolCredentialToFormSchemas, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
|
||||||
import TriggerForm from './trigger-form'
|
|
||||||
import Button from '@/app/components/base/button'
|
|
||||||
import Input from '@/app/components/base/input'
|
|
||||||
|
|
||||||
type ParametersFormProps = {
|
|
||||||
provider: TriggerWithProvider
|
|
||||||
trigger?: Event
|
|
||||||
builderId: string
|
|
||||||
parametersValue: Record<string, any>
|
|
||||||
propertiesValue: Record<string, any>
|
|
||||||
subscriptionName: string
|
|
||||||
onParametersChange: (value: Record<string, any>) => void
|
|
||||||
onPropertiesChange: (value: Record<string, any>) => void
|
|
||||||
onSubscriptionNameChange: (value: string) => void
|
|
||||||
onSubmit: () => void
|
|
||||||
onCancel: () => void
|
|
||||||
isLoading?: boolean
|
|
||||||
readOnly?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const ParametersForm: FC<ParametersFormProps> = ({
|
|
||||||
provider,
|
|
||||||
trigger,
|
|
||||||
builderId,
|
|
||||||
parametersValue,
|
|
||||||
propertiesValue,
|
|
||||||
subscriptionName,
|
|
||||||
onParametersChange,
|
|
||||||
onPropertiesChange,
|
|
||||||
onSubscriptionNameChange,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
isLoading = false,
|
|
||||||
readOnly = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
// Use the first trigger if no specific trigger is provided
|
|
||||||
// This is needed for dynamic options API which requires a trigger action
|
|
||||||
const currentTrigger = trigger || provider.triggers?.[0]
|
|
||||||
|
|
||||||
const parametersSchema = useMemo(() => {
|
|
||||||
if (!provider.subscription_schema?.parameters_schema) return []
|
|
||||||
return toolParametersToFormSchemas(provider.subscription_schema.parameters_schema as any)
|
|
||||||
}, [provider.subscription_schema?.parameters_schema])
|
|
||||||
|
|
||||||
const propertiesSchema = useMemo(() => {
|
|
||||||
if (!provider.subscription_schema?.properties_schema) return []
|
|
||||||
return toolCredentialToFormSchemas(provider.subscription_schema.properties_schema as any)
|
|
||||||
}, [provider.subscription_schema?.properties_schema])
|
|
||||||
|
|
||||||
const hasParameters = parametersSchema.length > 0
|
|
||||||
const hasProperties = propertiesSchema.length > 0
|
|
||||||
|
|
||||||
if (!hasParameters && !hasProperties) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8">
|
|
||||||
<p className="mb-4 text-text-tertiary">
|
|
||||||
{t('workflow.nodes.triggerPlugin.noConfigurationRequired')}
|
|
||||||
</p>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button onClick={onCancel}>
|
|
||||||
{t('common.operation.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={onSubmit}
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{t('common.operation.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Subscription Name Section */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-text-primary">
|
|
||||||
{t('workflow.nodes.triggerPlugin.subscriptionName')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-text-tertiary">
|
|
||||||
{t('workflow.nodes.triggerPlugin.subscriptionNameDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={subscriptionName}
|
|
||||||
onChange={e => onSubscriptionNameChange(e.target.value)}
|
|
||||||
placeholder={t('workflow.nodes.triggerPlugin.subscriptionNamePlaceholder')}
|
|
||||||
readOnly={readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parameters Section */}
|
|
||||||
{hasParameters && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-text-primary">
|
|
||||||
{t('workflow.nodes.triggerPlugin.parameters')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-text-tertiary">
|
|
||||||
{t('workflow.nodes.triggerPlugin.parametersDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<TriggerForm
|
|
||||||
readOnly={readOnly}
|
|
||||||
nodeId=""
|
|
||||||
schema={parametersSchema as any}
|
|
||||||
value={parametersValue}
|
|
||||||
onChange={onParametersChange}
|
|
||||||
currentTrigger={currentTrigger}
|
|
||||||
currentProvider={provider}
|
|
||||||
extraParams={{ subscription_builder_id: builderId }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Properties Section */}
|
|
||||||
{hasProperties && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-text-primary">
|
|
||||||
{t('workflow.nodes.triggerPlugin.properties')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-text-tertiary">
|
|
||||||
{t('workflow.nodes.triggerPlugin.propertiesDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<TriggerForm
|
|
||||||
readOnly={readOnly}
|
|
||||||
nodeId=""
|
|
||||||
schema={propertiesSchema as any}
|
|
||||||
value={propertiesValue}
|
|
||||||
onChange={onPropertiesChange}
|
|
||||||
currentTrigger={currentTrigger}
|
|
||||||
currentProvider={provider}
|
|
||||||
extraParams={{ subscription_builder_id: builderId }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-end space-x-2 border-t border-divider-subtle pt-4">
|
|
||||||
<Button onClick={onCancel}>
|
|
||||||
{t('common.operation.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={onSubmit}
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{t('common.operation.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ParametersForm
|
|
||||||
Loading…
Reference in New Issue
Block a user