feat: add subscription in node

This commit is contained in:
yessenia 2025-09-28 20:49:22 +08:00
parent 48597ef193
commit 973b937ba5
20 changed files with 811 additions and 710 deletions

View File

@ -0,0 +1,273 @@
'use client'
import type { ErrorInfo, ReactNode } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { RiAlertLine, RiBugLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
type ErrorBoundaryState = {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
errorCount: number
}
type ErrorBoundaryProps = {
children: ReactNode
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode)
onError?: (error: Error, errorInfo: ErrorInfo) => void
onReset?: () => void
showDetails?: boolean
className?: string
resetKeys?: Array<string | number>
resetOnPropsChange?: boolean
isolate?: boolean
enableRecovery?: boolean
customTitle?: string
customMessage?: string
}
// Internal class component for error catching
class ErrorBoundaryInner extends React.Component<
ErrorBoundaryProps & {
resetErrorBoundary: () => void
onResetKeysChange: (prevResetKeys?: Array<string | number>) => void
},
ErrorBoundaryState
> {
constructor(props: any) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
errorCount: 0,
}
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return {
hasError: true,
error,
}
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (process.env.NODE_ENV === 'development') {
console.error('ErrorBoundary caught an error:', error)
console.error('Error Info:', errorInfo)
}
this.setState(prevState => ({
errorInfo,
errorCount: prevState.errorCount + 1,
}))
if (this.props.onError)
this.props.onError(error, errorInfo)
}
componentDidUpdate(prevProps: any) {
const { resetKeys, resetOnPropsChange } = this.props
const { hasError } = this.state
if (hasError && prevProps.resetKeys !== resetKeys) {
if (resetKeys?.some((key, idx) => key !== prevProps.resetKeys?.[idx]))
this.props.resetErrorBoundary()
}
if (hasError && resetOnPropsChange && prevProps.children !== this.props.children)
this.props.resetErrorBoundary()
if (prevProps.resetKeys !== resetKeys)
this.props.onResetKeysChange(prevProps.resetKeys)
}
render() {
const { hasError, error, errorInfo, errorCount } = this.state
const {
fallback,
children,
showDetails = false,
className,
isolate = true,
enableRecovery = true,
customTitle,
customMessage,
resetErrorBoundary,
} = this.props
if (hasError && error) {
if (fallback) {
if (typeof fallback === 'function')
return fallback(error, resetErrorBoundary)
return fallback
}
return (
<div
className={cn(
'border-state-critical-border bg-state-critical-hover-alt flex flex-col items-center justify-center rounded-lg border p-8',
isolate && 'min-h-[200px]',
className,
)}
>
<div className='mb-4 flex items-center gap-2'>
<RiAlertLine className='text-state-critical-solid h-8 w-8' />
<h2 className='text-xl font-semibold text-text-primary'>
{customTitle || 'Something went wrong'}
</h2>
</div>
<p className='mb-6 text-center text-text-secondary'>
{customMessage || 'An unexpected error occurred while rendering this component.'}
</p>
{showDetails && errorInfo && (
<details className='mb-6 w-full max-w-2xl'>
<summary className='mb-2 cursor-pointer text-sm font-medium text-text-tertiary hover:text-text-secondary'>
<span className='inline-flex items-center gap-1'>
<RiBugLine className='h-4 w-4' />
Error Details (Development Only)
</span>
</summary>
<div className='rounded-lg bg-gray-100 p-4'>
<div className='mb-2'>
<span className='font-mono text-xs font-semibold text-gray-600'>Error:</span>
<pre className='mt-1 overflow-auto whitespace-pre-wrap font-mono text-xs text-gray-800'>
{error.toString()}
</pre>
</div>
{errorInfo && (
<div>
<span className='font-mono text-xs font-semibold text-gray-600'>Component Stack:</span>
<pre className='mt-1 max-h-40 overflow-auto whitespace-pre-wrap font-mono text-xs text-gray-700'>
{errorInfo.componentStack}
</pre>
</div>
)}
{errorCount > 1 && (
<div className='mt-2 text-xs text-gray-600'>
This error has occurred {errorCount} times
</div>
)}
</div>
</details>
)}
{enableRecovery && (
<div className='flex gap-3'>
<Button
variant='primary'
size='small'
onClick={resetErrorBoundary}
>
Try Again
</Button>
<Button
variant='secondary'
size='small'
onClick={() => window.location.reload()}
>
Reload Page
</Button>
</div>
)}
</div>
)
}
return children
}
}
// Main functional component wrapper
const ErrorBoundary: React.FC<ErrorBoundaryProps> = (props) => {
const [errorBoundaryKey, setErrorBoundaryKey] = useState(0)
const resetKeysRef = useRef(props.resetKeys)
const prevResetKeysRef = useRef<Array<string | number> | undefined>(undefined)
const resetErrorBoundary = useCallback(() => {
setErrorBoundaryKey(prev => prev + 1)
props.onReset?.()
}, [props])
const onResetKeysChange = useCallback((prevResetKeys?: Array<string | number>) => {
prevResetKeysRef.current = prevResetKeys
}, [])
useEffect(() => {
if (prevResetKeysRef.current !== props.resetKeys)
resetKeysRef.current = props.resetKeys
}, [props.resetKeys])
return (
<ErrorBoundaryInner
{...props}
key={errorBoundaryKey}
resetErrorBoundary={resetErrorBoundary}
onResetKeysChange={onResetKeysChange}
/>
)
}
// Hook for imperative error handling
export function useErrorHandler() {
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
if (error)
throw error
}, [error])
return setError
}
// Hook for catching async errors
export function useAsyncError() {
const [, setError] = useState()
return useCallback(
(error: Error) => {
setError(() => {
throw error
})
},
[setError],
)
}
// HOC for wrapping components with error boundary
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>,
): React.ComponentType<P> {
const WrappedComponent = (props: P) => (
<ErrorBoundary {...errorBoundaryProps}>
<Component {...props} />
</ErrorBoundary>
)
WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name || 'Component'})`
return WrappedComponent
}
// Simple error fallback component
export const ErrorFallback: React.FC<{
error: Error
resetErrorBoundary: () => void
}> = ({ error, resetErrorBoundary }) => {
return (
<div className='flex min-h-[200px] flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 p-8'>
<h2 className='mb-2 text-lg font-semibold text-red-800'>Oops! Something went wrong</h2>
<p className='mb-4 text-center text-red-600'>{error.message}</p>
<Button onClick={resetErrorBoundary} size='small'>
Try again
</Button>
</div>
)
}
export default ErrorBoundary

View File

@ -1,18 +1,18 @@
'use client'
import React, { useEffect } from 'react'
import type { FC } from 'react'
import DetailHeader from './detail-header'
import EndpointList from './endpoint-list'
import ActionList from './action-list'
import DatasourceActionList from './datasource-action-list'
import ModelList from './model-list'
import AgentStrategyList from './agent-strategy-list'
import { SubscriptionList } from './subscription-list'
import { TriggerEventsList } from './trigger-events-list'
import Drawer from '@/app/components/base/drawer'
import { type PluginDetail, PluginType } from '@/app/components/plugins/types'
import cn from '@/utils/classnames'
import { usePluginStore } from './store'
import type { FC } from 'react'
import { useEffect } from 'react'
import ActionList from './action-list'
import AgentStrategyList from './agent-strategy-list'
import DatasourceActionList from './datasource-action-list'
import DetailHeader from './detail-header'
import EndpointList from './endpoint-list'
import ModelList from './model-list'
import { SubscriptionList } from './subscription-list'
import { usePluginStore } from './subscription-list/store'
import { TriggerEventsList } from './trigger-events-list'
type Props = {
detail?: PluginDetail
@ -33,8 +33,13 @@ const PluginDetailPanel: FC<Props> = ({
const { setDetail } = usePluginStore()
useEffect(() => {
if (detail)
setDetail(detail)
if (detail) {
setDetail({
plugin_id: detail.plugin_id,
provider: `${detail.plugin_id}/${detail.declaration.name}`,
declaration: detail.declaration,
})
}
}, [detail])
if (!detail)

View File

@ -1,22 +0,0 @@
import { create } from 'zustand'
import type { PluginDetail } from '../types'
type Shape = {
detail: PluginDetail | undefined
setDetail: (detail: PluginDetail) => void
}
export const usePluginStore = create<Shape>(set => ({
detail: undefined,
setDetail: (detail: PluginDetail) => set({ detail }),
}))
type ShapeSubscription = {
refresh?: () => void
setRefresh: (refresh: () => void) => void
}
export const usePluginSubscriptionStore = create<ShapeSubscription>(set => ({
refresh: undefined,
setRefresh: (refresh: () => void) => set({ refresh }),
}))

View File

@ -17,8 +17,8 @@ import {
import { RiLoader2Line } from '@remixicon/react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore, usePluginSubscriptionStore } from '../../store'
import LogViewer from '../log-viewer'
import { usePluginStore, usePluginSubscriptionStore } from '../store'
type Props = {
onClose: () => void
@ -72,7 +72,6 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const { mutate: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder()
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
const providerName = `${detail?.plugin_id}/${detail?.declaration.name}`
const propertiesSchema = detail?.declaration.trigger.subscription_schema.properties_schema || [] // manual
const subscriptionFormRef = React.useRef<FormRefObject>(null)
const propertiesFormRef = React.useRef<FormRefObject>(null)
@ -82,7 +81,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const credentialsFormRef = React.useRef<FormRefObject>(null)
const { data: logData } = useTriggerSubscriptionBuilderLogs(
providerName,
detail?.provider || '',
subscriptionBuilder?.id || '',
{
enabled: createType === SupportedCreationMethods.MANUAL && !!subscriptionBuilder?.id,
@ -94,7 +93,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
if (!subscriptionBuilder) {
createBuilder(
{
provider: providerName,
provider: detail?.provider || '',
credential_type: CREDENTIAL_TYPE_MAP[createType],
},
{
@ -112,7 +111,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
},
)
}
}, [createBuilder, providerName, subscriptionBuilder, t])
}, [createBuilder, detail?.provider, subscriptionBuilder, t])
const handleVerify = () => {
const credentialsFormValues = credentialsFormRef.current?.getFormValues({}) || { values: {}, isCheckValidated: false }
@ -130,7 +129,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
verifyCredentials(
{
provider: providerName,
provider: detail?.provider || '',
subscriptionBuilderId: subscriptionBuilder?.id || '',
credentials,
},
@ -164,7 +163,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
buildSubscription(
{
provider: providerName,
provider: detail?.provider || '',
subscriptionBuilderId: subscriptionBuilder.id,
name: subscriptionNameValue,
parameters: { ...parameterForm.values, events: ['*'] },
@ -267,11 +266,11 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
</div> */}
{createType !== SupportedCreationMethods.MANUAL && parametersSchema.length > 0 && (
<BaseForm
formSchemas={parametersSchema.map(schema => ({
formSchemas={parametersSchema.map((schema: { type: FormTypeEnum; name: any }) => ({
...schema,
dynamicSelectParams: schema.type === FormTypeEnum.dynamicSelect ? {
plugin_id: detail?.plugin_id || '',
provider: providerName,
provider: detail?.provider || '',
action: 'provider',
parameter: schema.name,
credential_id: subscriptionBuilder?.id || '',
@ -306,7 +305,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
<RiLoader2Line className='h-full w-full animate-spin' />
</div>
<div className='system-xs-regular text-text-tertiary'>
Awaiting request from {detail?.declaration.name}...
Awaiting request from {detail?.plugin_id}...
</div>
</div>
<LogViewer logs={logData?.logs || []} />

View File

@ -14,7 +14,7 @@ import { useBoolean } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SupportedCreationMethods } from '../../../types'
import { usePluginStore } from '../../store'
import { usePluginStore } from '../store'
import { CommonCreateModal } from './common-modal'
import { OAuthClientSettingsModal } from './oauth-client'
@ -35,11 +35,10 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
const [selectedCreateInfo, setSelectedCreateInfo] = useState<{ type: SupportedCreationMethods, builder?: TriggerSubscriptionBuilder } | null>(null)
const detail = usePluginStore(state => state.detail)
const provider = `${detail?.plugin_id}/${detail?.declaration.name}`
const { data: providerInfo } = useTriggerProviderInfo(provider, !!detail?.plugin_id && !!detail?.declaration.name)
const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '')
const supportedMethods = providerInfo?.supported_creation_methods || []
const { data: oauthConfig } = useTriggerOAuthConfig(provider, supportedMethods.includes(SupportedCreationMethods.OAUTH))
const { data: oauthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH))
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
const methodType = supportedMethods.length === 1 ? supportedMethods[0] : DEFAULT_METHOD
@ -94,7 +93,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU
const onChooseCreateType = (type: SupportedCreationMethods) => {
if (type === SupportedCreationMethods.OAUTH) {
if (oauthConfig?.configured) {
initiateOAuth(provider, {
initiateOAuth(detail?.provider || '', {
onSuccess: (response) => {
openOAuthPopup(response.authorization_url, (callbackData) => {
if (callbackData) {

View File

@ -19,7 +19,7 @@ import {
} from '@remixicon/react'
import React, { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore } from '../../store'
import { usePluginStore } from '../store'
type Props = {
oauthConfig?: TriggerOAuthConfig

View File

@ -0,0 +1,46 @@
import Confirm from '@/app/components/base/confirm'
import { usePluginSubscriptionStore } from './store'
import { useDeleteTriggerSubscription } from '@/service/use-triggers'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
type Props = {
onClose: (deleted: boolean) => void
isShow: boolean
currentId: string
currentName: string
}
export const DeleteConfirm = (props: Props) => {
const { onClose, isShow, currentId, currentName } = props
const { refresh } = usePluginSubscriptionStore()
const { mutate: deleteSubscription, isPending: isDeleting } = useDeleteTriggerSubscription()
const { t } = useTranslation()
const onConfirm = () => {
deleteSubscription(currentId, {
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.subscription.list.item.actions.deleteConfirm.title'),
})
refresh?.()
onClose(true)
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || 'Failed to delete subscription',
})
},
})
}
return <Confirm
title={t('pluginTrigger.subscription.list.item.actions.deleteConfirm.title')}
content={t('pluginTrigger.subscription.list.item.actions.deleteConfirm.content', { name: currentName })}
isShow={isShow}
isLoading={isDeleting}
onConfirm={onConfirm}
onCancel={() => onClose(false)}
/>
}

View File

@ -1,64 +1,50 @@
import Tooltip from '@/app/components/base/tooltip'
import { useTriggerSubscriptions } from '@/service/use-triggers'
import cn from '@/utils/classnames'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore, usePluginSubscriptionStore } from '../store'
import { CreateButtonType, CreateSubscriptionButton } from './create'
import SubscriptionCard from './subscription-card'
import { withErrorBoundary } from '@/app/components/base/error-boundary'
import { SubscriptionListView } from './list-view'
import { SubscriptionSelectorView } from './selector-view'
import { useSubscriptionList } from './use-subscription-list'
export const SubscriptionList = () => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
export enum SubscriptionListMode {
PANEL = 'panel',
SELECTOR = 'selector',
}
const showTopBorder = detail?.declaration.tool || detail?.declaration.endpoint
const provider = `${detail?.plugin_id}/${detail?.declaration.name}`
type SubscriptionListProps = {
mode?: SubscriptionListMode
selectedId?: string
onSelect?: ({ id, name }: { id: string, name: string }) => void
}
const { data: subscriptions, isLoading, refetch } = useTriggerSubscriptions(provider, !!detail?.plugin_id && !!detail?.declaration.name)
export { SubscriptionSelectorEntry } from './selector-entry'
const { setRefresh } = usePluginSubscriptionStore()
export const SubscriptionList = withErrorBoundary(({
mode = SubscriptionListMode.PANEL,
selectedId,
onSelect,
}: SubscriptionListProps) => {
const { subscriptions, isLoading, hasSubscriptions } = useSubscriptionList()
useEffect(() => {
if (refetch)
setRefresh(refetch)
}, [refetch])
// console.log('detail', detail)
if (isLoading) {
if (mode === SubscriptionListMode.SELECTOR) {
return (
<div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}>
<div className='flex items-center justify-center py-8'>
<div className='text-text-tertiary'>{t('common.dataLoading')}</div>
</div>
</div>
<SubscriptionSelectorView
subscriptions={subscriptions}
isLoading={isLoading}
hasSubscriptions={hasSubscriptions}
selectedId={selectedId}
onSelect={onSelect}
/>
)
}
const hasSubscriptions = subscriptions && subscriptions.length > 0
// const showTopBorder = !!(detail?.declaration?.tool || detail?.declaration?.endpoint)
return (
<div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}>
<div className='relative mb-3 flex items-center justify-between'>
{
hasSubscriptions
&& <div className='flex shrink-0 items-center gap-1'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })}
</span>
<Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} />
</div>
}
<CreateSubscriptionButton buttonType={hasSubscriptions ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON} />
</div>
{hasSubscriptions
&& <div className='flex flex-col gap-1'>
{subscriptions?.map(subscription => (
<SubscriptionCard
key={subscription.id}
data={subscription}
/>
))}
</div>}
</div>
<SubscriptionListView
subscriptions={subscriptions}
isLoading={isLoading}
// showTopBorder={showTopBorder}
hasSubscriptions={hasSubscriptions}
/>
)
}
})

View File

@ -0,0 +1,63 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import { CreateButtonType, CreateSubscriptionButton } from './create'
import SubscriptionCard from './subscription-card'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
type SubscriptionListViewProps = {
subscriptions?: TriggerSubscription[]
isLoading: boolean
showTopBorder?: boolean
hasSubscriptions: boolean
}
export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
subscriptions,
isLoading,
showTopBorder = false,
hasSubscriptions,
}) => {
const { t } = useTranslation()
if (isLoading) {
return (
<div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}>
<div className='flex items-center justify-center py-8'>
<div className='text-text-tertiary'>{t('common.dataLoading')}</div>
</div>
</div>
)
}
return (
<div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}>
<div className='relative mb-3 flex items-center justify-between'>
{hasSubscriptions && (
<div className='flex shrink-0 items-center gap-1'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })}
</span>
<Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} />
</div>
)}
<CreateSubscriptionButton
buttonType={hasSubscriptions ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON}
/>
</div>
{hasSubscriptions && (
<div className='flex flex-col gap-1'>
{subscriptions?.map(subscription => (
<SubscriptionCard
key={subscription.id}
data={subscription}
/>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,109 @@
'use client'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { SubscriptionList, SubscriptionListMode } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import cn from '@/utils/classnames'
import { RiArrowDownSLine } from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSubscriptionList } from './use-subscription-list'
type SubscriptionTriggerButtonProps = {
selectedId?: string
onClick?: () => void
isOpen?: boolean
className?: string
}
const SubscriptionTriggerButton: React.FC<SubscriptionTriggerButtonProps> = ({
selectedId,
onClick,
isOpen = false,
className,
}) => {
const { t } = useTranslation()
const { subscriptions } = useSubscriptionList()
const statusConfig = useMemo(() => {
if (!selectedId) {
if (isOpen) {
return {
label: t('workflow.nodes.triggerPlugin.selectSubscription'),
color: 'yellow' as const,
}
}
return {
label: 'No subscription selected',
color: 'red' as const,
}
}
return {
label: subscriptions?.find(sub => sub.id === selectedId)?.name || '--',
color: 'green' as const,
}
}, [selectedId, subscriptions, t, isOpen])
return (
<button
className={cn(
'flex h-8 items-center gap-1 rounded-lg px-2 transition-colors',
'hover:bg-state-base-hover-alt',
isOpen && 'bg-state-base-hover-alt',
className,
)}
onClick={onClick}
>
<Indicator
className='shrink-0'
color={statusConfig.color}
/>
<span className={cn('system-xs-medium truncate text-components-button-ghost-text', statusConfig.color === 'red' && 'text-components-button-destructive-secondary-text')}>
{statusConfig.label}
</span>
<RiArrowDownSLine
className={cn(
'ml-auto h-4 w-4 shrink-0 text-text-quaternary transition-transform',
isOpen && 'rotate-180',
)}
/>
</button>
)
}
export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: {
selectedId?: string,
onSelect: ({ id, name }: { id: string, name: string }) => void
}) => {
const [isOpen, setIsOpen] = useState(false)
return <PortalToFollowElem
placement='bottom-start'
offset={4}
open={isOpen}
onOpenChange={setIsOpen}
>
<PortalToFollowElemTrigger asChild>
<div>
<SubscriptionTriggerButton
selectedId={selectedId}
onClick={() => setIsOpen(!isOpen)}
isOpen={isOpen}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<div className='rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<SubscriptionList
mode={SubscriptionListMode.SELECTOR}
selectedId={selectedId}
onSelect={onSelect}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
}

View File

@ -0,0 +1,112 @@
'use client'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import cn from '@/utils/classnames'
import { RiCheckLine, RiDeleteBinLine } from '@remixicon/react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreateButtonType, CreateSubscriptionButton } from './create'
import { DeleteConfirm } from './delete-confirm'
type SubscriptionSelectorProps = {
subscriptions?: TriggerSubscription[]
isLoading: boolean
hasSubscriptions: boolean
selectedId?: string
onSelect?: ({ id, name }: { id: string, name: string }) => void
}
export const SubscriptionSelectorView: React.FC<SubscriptionSelectorProps> = ({
subscriptions,
isLoading,
hasSubscriptions,
selectedId,
onSelect,
}) => {
const { t } = useTranslation()
const [deletedSubscription, setDeletedSubscription] = useState<TriggerSubscription | null>(null)
if (isLoading) {
return (
<div className='flex items-center justify-center py-8'>
<div className='text-text-tertiary'>{t('common.dataLoading')}</div>
</div>
)
}
return (
<div className='w-[320px] p-1'>
{hasSubscriptions && <div className='ml-7 mr-1.5 mt-0.5 flex items-center justify-between'>
<div className='flex shrink-0 items-center gap-1'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })}
</span>
<Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} />
</div>
<CreateSubscriptionButton
buttonType={CreateButtonType.ICON_BUTTON}
/>
</div>}
<div className='max-h-[320px] overflow-y-auto'>
{hasSubscriptions ? (
<>
{subscriptions?.map(subscription => (
<button
key={subscription.id}
className={cn(
'group flex w-full items-center justify-between rounded-lg p-1 text-left transition-colors',
'hover:bg-state-base-hover has-[.subscription-delete-btn:hover]:!bg-state-destructive-hover',
selectedId === subscription.id && 'bg-state-base-hover',
)}
onClick={() => onSelect?.(subscription)}
>
<div className='flex items-center'>
{selectedId === subscription.id && (
<RiCheckLine className='mr-2 h-4 w-4 shrink-0 text-text-accent' />
)}
<Indicator
color={subscription.properties?.active !== false ? 'green' : 'red'}
className={cn('mr-1.5', selectedId !== subscription.id && 'ml-6')}
/>
<span className='system-md-regular leading-6 text-text-secondary'>
{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>
<CreateSubscriptionButton
buttonType={CreateButtonType.FULL_BUTTON}
/>
</div>
)}
</div>
{deletedSubscription && (
<DeleteConfirm
onClose={(deleted) => {
if (deleted)
onSelect?.({ id: '', name: '' })
setDeletedSubscription(null)
}}
isShow={!!deletedSubscription}
currentId={deletedSubscription.id}
currentName={deletedSubscription.name}
/>
)}
</div>
)
}

View File

@ -0,0 +1,37 @@
import { create } from 'zustand'
export type SubscriptionListDetail = {
plugin_id: string
// name: string
provider: string
declaration: {
tool?: any
endpoint?: any
trigger?: any
name?: string
meta?: {
version?: string
}
}
version?: string
}
type Shape = {
detail: SubscriptionListDetail | undefined
setDetail: (detail: SubscriptionListDetail) => void
}
export const usePluginStore = create<Shape>(set => ({
detail: undefined,
setDetail: (detail: SubscriptionListDetail) => set({ detail }),
}))
type ShapeSubscription = {
refresh?: () => void
setRefresh: (refresh: () => void) => void
}
export const usePluginSubscriptionStore = create<ShapeSubscription>(set => ({
refresh: undefined,
setRefresh: (refresh: () => void) => set({ refresh }),
}))

View File

@ -1,10 +1,7 @@
'use client'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { useDeleteTriggerSubscription } from '@/service/use-triggers'
import cn from '@/utils/classnames'
import {
RiDeleteBinLine,
@ -12,7 +9,7 @@ import {
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { usePluginSubscriptionStore } from '../store'
import { DeleteConfirm } from './delete-confirm'
type Props = {
data: TriggerSubscription
@ -24,28 +21,6 @@ const SubscriptionCard = ({ data }: Props) => {
setTrue: showDeleteModal,
setFalse: hideDeleteModal,
}] = useBoolean(false)
const { refresh } = usePluginSubscriptionStore()
const { mutate: deleteSubscription, isPending: isDeleting } = useDeleteTriggerSubscription()
const handleDelete = () => {
deleteSubscription(data.id, {
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.subscription.list.item.actions.deleteConfirm.title'),
})
refresh?.()
hideDeleteModal()
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || 'Failed to delete subscription',
})
},
})
}
const isActive = data.properties?.active !== false
@ -96,13 +71,11 @@ const SubscriptionCard = ({ data }: Props) => {
</div>
{isShowDeleteModal && (
<Confirm
title={t('pluginTrigger.subscription.list.item.actions.deleteConfirm.title')}
content={t('pluginTrigger.subscription.list.item.actions.deleteConfirm.content', { name: data.name })}
<DeleteConfirm
onClose={hideDeleteModal}
isShow={isShowDeleteModal}
onConfirm={handleDelete}
onCancel={hideDeleteModal}
isLoading={isDeleting}
currentId={data.id}
currentName={data.name}
/>
)}
</>

View File

@ -1,237 +0,0 @@
'use client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiCloseLine,
RiEditLine,
RiKeyLine,
RiUserLine,
} from '@remixicon/react'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import type { PluginDetail } from '@/app/components/plugins/types'
type Props = {
pluginDetail: PluginDetail
onCancel: () => void
onSaved: (data: any) => void
}
type CreateMode = 'api-key' | 'oauth' | 'manual'
const SubscriptionModal = ({ pluginDetail, onCancel, onSaved }: Props) => {
const { t } = useTranslation()
const [selectedMode, setSelectedMode] = useState<CreateMode | null>(null)
const [subscriptionName, setSubscriptionName] = useState('')
const [apiKey, setApiKey] = useState('')
const [webhookUrl, setWebhookUrl] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleModeSelect = (mode: CreateMode) => {
setSelectedMode(mode)
}
const handleBack = () => {
setSelectedMode(null)
}
const handleCreate = async () => {
if (!selectedMode || !subscriptionName.trim()) return
setIsLoading(true)
try {
const subscriptionData = {
name: subscriptionName,
mode: selectedMode,
plugin_id: pluginDetail.plugin_id,
...(selectedMode === 'api-key' && { api_key: apiKey }),
...(selectedMode === 'manual' && { webhook_url: webhookUrl }),
}
onSaved(subscriptionData)
}
finally {
setIsLoading(false)
}
}
const canCreate = subscriptionName.trim() && (
selectedMode === 'oauth'
|| (selectedMode === 'api-key' && apiKey.trim())
|| (selectedMode === 'manual' && webhookUrl.trim())
)
if (!selectedMode) {
return (
<Modal
isShow
onClose={onCancel}
className='!max-w-[520px] !p-0'
>
<div className='flex items-center justify-between p-6 pb-4'>
<h3 className='text-lg font-semibold text-text-primary'>
{t('plugin.detailPanel.createSubscription')}
</h3>
<Button variant='ghost' size='small' onClick={onCancel}>
<RiCloseLine className='h-4 w-4' />
</Button>
</div>
<div className='px-6 pb-2'>
<p className='system-sm-regular mb-4 text-text-secondary'>
{t('plugin.detailPanel.createSubscriptionDesc')}
</p>
</div>
<div className='px-6 pb-6'>
<div className='space-y-3'>
<button
onClick={() => handleModeSelect('api-key')}
className='flex w-full items-center gap-3 rounded-lg border border-components-panel-border p-4 text-left transition-colors hover:bg-background-default-hover'
>
<div className='flex h-10 w-10 items-center justify-center rounded-lg bg-background-default-subtle'>
<RiKeyLine className='h-5 w-5 text-text-warning' />
</div>
<div className='flex-1'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.detailPanel.createViaApiKey')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.detailPanel.createViaApiKeyDesc')}
</div>
</div>
</button>
<button
onClick={() => handleModeSelect('oauth')}
className='flex w-full items-center gap-3 rounded-lg border border-components-panel-border p-4 text-left transition-colors hover:bg-background-default-hover'
>
<div className='flex h-10 w-10 items-center justify-center rounded-lg bg-background-default-subtle'>
<RiUserLine className='h-5 w-5 text-text-accent' />
</div>
<div className='flex-1'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.detailPanel.createViaOAuth')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.detailPanel.createViaOAuthDesc')}
</div>
</div>
</button>
<button
onClick={() => handleModeSelect('manual')}
className='flex w-full items-center gap-3 rounded-lg border border-components-panel-border p-4 text-left transition-colors hover:bg-background-default-hover'
>
<div className='flex h-10 w-10 items-center justify-center rounded-lg bg-background-default-subtle'>
<RiEditLine className='h-5 w-5 text-text-secondary' />
</div>
<div className='flex-1'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.detailPanel.createManual')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.detailPanel.createManualDesc')}
</div>
</div>
</button>
</div>
</div>
</Modal>
)
}
return (
<Modal
isShow
onClose={onCancel}
className='!max-w-[520px] !p-0'
>
<div className='flex items-center justify-between p-6 pb-4'>
<div className='flex items-center gap-3'>
<Button variant='ghost' size='small' onClick={handleBack}>
</Button>
<h3 className='text-lg font-semibold text-text-primary'>
{selectedMode === 'api-key' && t('plugin.detailPanel.createViaApiKey')}
{selectedMode === 'oauth' && t('plugin.detailPanel.createViaOAuth')}
{selectedMode === 'manual' && t('plugin.detailPanel.createManual')}
</h3>
</div>
<Button variant='ghost' size='small' onClick={onCancel}>
<RiCloseLine className='h-4 w-4' />
</Button>
</div>
<div className='px-6 pb-6'>
<div className='space-y-4'>
<div>
<label className='system-sm-medium mb-2 block text-text-primary'>
{t('plugin.detailPanel.subscriptionName')}
</label>
<Input
value={subscriptionName}
onChange={e => setSubscriptionName(e.target.value)}
placeholder={t('plugin.detailPanel.subscriptionNamePlaceholder')}
className='w-full'
/>
</div>
{selectedMode === 'api-key' && (
<div>
<label className='system-sm-medium mb-2 block text-text-primary'>
{t('plugin.detailPanel.apiKey')}
</label>
<Input
type='password'
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder={t('plugin.detailPanel.apiKeyPlaceholder')}
className='w-full'
/>
</div>
)}
{selectedMode === 'oauth' && (
<div className='rounded-lg bg-background-section p-4'>
<p className='system-sm-regular text-text-secondary'>
{t('plugin.detailPanel.oauthCreateNote')}
</p>
</div>
)}
{selectedMode === 'manual' && (
<div>
<label className='system-sm-medium mb-2 block text-text-primary'>
{t('plugin.detailPanel.webhookUrl')}
</label>
<Input
value={webhookUrl}
onChange={e => setWebhookUrl(e.target.value)}
placeholder={t('plugin.detailPanel.webhookUrlPlaceholder')}
className='w-full'
/>
</div>
)}
</div>
<div className='mt-6 flex justify-end gap-2'>
<Button variant='secondary' onClick={onCancel}>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
onClick={handleCreate}
disabled={!canCreate}
loading={isLoading}
>
{t('common.operation.create')}
</Button>
</div>
</div>
</Modal>
)
}
export default SubscriptionModal

View File

@ -0,0 +1,23 @@
import { useEffect } from 'react'
import { useTriggerSubscriptions } from '@/service/use-triggers'
import { usePluginStore, usePluginSubscriptionStore } from './store'
export const useSubscriptionList = () => {
const detail = usePluginStore(state => state.detail)
const { setRefresh } = usePluginSubscriptionStore()
const { data: subscriptions, isLoading, refetch } = useTriggerSubscriptions(detail?.provider || '', !!detail?.provider)
useEffect(() => {
if (refetch)
setRefresh(refetch)
}, [refetch, setRefresh])
return {
detail,
subscriptions,
isLoading,
refetch,
hasSubscriptions: !!(subscriptions && subscriptions.length > 0),
}
}

View File

@ -1,15 +1,16 @@
import React, { useContext, useMemo } from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import ToolItem from '@/app/components/tools/provider/tool-item'
import { usePluginStore } from './store'
import { useTriggerProviderInfo } from '@/service/use-triggers'
import type { Tool, ToolParameter } from '@/app/components/tools/types'
import { CollectionType } from '@/app/components/tools/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Trigger } from '@/app/components/plugins/types'
import { usePluginStore } from './subscription-list/store'
type TriggerOption = {
value: string
@ -79,7 +80,7 @@ const toTool = (trigger: Trigger, fallbackAuthor: string): Tool => {
export const TriggerEventsList = () => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const locale = useContextSelector(I18n, state => state.locale)
const language = getLanguage(locale)
const detail = usePluginStore(state => state.detail)
const triggers = detail?.declaration.trigger?.triggers || []

View File

@ -52,7 +52,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { useStore } from '@/app/components/workflow/store'
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'
@ -275,20 +275,20 @@ const BasePanel: FC<BasePanelProps> = ({
return triggerProviders.find(p => p.plugin_id === data.provider_id && p.name === data.provider_name)
}, [data.type, data.provider_id, data.provider_name, triggerProviders])
const supportedAuthMethods = useMemo(() => {
if (!currentTriggerProvider) return []
const methods = []
if (currentTriggerProvider.oauth_client_schema && currentTriggerProvider.oauth_client_schema.length > 0)
methods.push('oauth')
if (currentTriggerProvider.credentials_schema && currentTriggerProvider.credentials_schema.length > 0)
methods.push('api_key')
return methods
}, [currentTriggerProvider])
// const supportedAuthMethods = useMemo(() => {
// if (!currentTriggerProvider) return []
// const methods = []
// if (currentTriggerProvider.oauth_client_schema && currentTriggerProvider.oauth_client_schema.length > 0)
// methods.push('oauth')
// if (currentTriggerProvider.credentials_schema && currentTriggerProvider.credentials_schema.length > 0)
// methods.push('api_key')
// return methods
// }, [currentTriggerProvider])
// Simplified: Always show auth selector for trigger plugins
const shouldShowTriggerAuthSelector = useMemo(() => {
return data.type === BlockEnum.TriggerPlugin && currentTriggerProvider && supportedAuthMethods.length > 0
}, [data.type, currentTriggerProvider, supportedAuthMethods.length])
// const shouldShowTriggerAuthSelector = useMemo(() => {
// return data.type === BlockEnum.TriggerPlugin && currentTriggerProvider && supportedAuthMethods.length > 0
// }, [data.type, currentTriggerProvider, supportedAuthMethods.length])
// Simplified: Always show tab for trigger plugins
const shouldShowTriggerTab = useMemo(() => {
@ -536,14 +536,14 @@ const BasePanel: FC<BasePanelProps> = ({
</PluginAuthInDataSourceNode>
)
}
{
{/* {
shouldShowTriggerAuthSelector && (
<AuthMethodSelector
provider={currentTriggerProvider!}
supportedMethods={supportedAuthMethods}
/>
)
}
} */}
{
shouldShowTriggerTab && (
<div className='flex items-center justify-between pl-4 pr-3'>

View File

@ -1,122 +1,24 @@
import type { FC } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AuthorizedInNode } from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { AuthCategory, AuthorizedInNode } from '@/app/components/plugins/plugin-auth'
import { SubscriptionMenu } from '@/app/components/workflow/nodes/trigger-plugin/components/subscription-menu'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum, type Node } from '@/app/components/workflow/types'
import { canFindTool } from '@/utils'
import { useStore } from '@/app/components/workflow/store'
import AuthenticationMenu from '@/app/components/workflow/nodes/trigger-plugin/components/authentication-menu'
import {
useDeleteTriggerSubscription,
useInitiateTriggerOAuth,
useInvalidateTriggerSubscriptions,
useTriggerSubscriptions,
} from '@/service/use-triggers'
import { useToastContext } from '@/app/components/base/toast'
import { openOAuthPopup } from '@/hooks/use-oauth'
import type { FC } from 'react'
import { memo, useMemo } from 'react'
type NodeAuthProps = {
data: Node['data']
onAuthorizationChange: (credential_id: string) => void
onSubscriptionChange?: (subscription_id: string) => void
onSubscriptionChange?: (id: string, name: string) => void
}
const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange, onSubscriptionChange }) => {
const { t } = useTranslation()
const buildInTools = useStore(s => s.buildInTools)
const { notify } = useToastContext()
// Construct the correct provider path for trigger plugins
// Format should be: plugin_id/provider_name (e.g., "langgenius/github_trigger/github_trigger")
const provider = useMemo(() => {
if (data.type === BlockEnum.TriggerPlugin) {
// If we have both plugin_id and provider_name, construct the full path
if (data.provider_name)
return data.provider_name
}
return data.provider_id || ''
}, [data.type, data.provider_id, data.provider_name])
// Always call hooks at the top level
const { data: subscriptions = [] } = useTriggerSubscriptions(
provider,
data.type === BlockEnum.TriggerPlugin && !!provider,
)
const deleteSubscription = useDeleteTriggerSubscription()
const initiateTriggerOAuth = useInitiateTriggerOAuth()
const invalidateSubscriptions = useInvalidateTriggerSubscriptions()
const currCollection = useMemo(() => {
return buildInTools.find(item => canFindTool(item.id, data.provider_id))
}, [buildInTools, data.provider_id])
// Get selected subscription ID from node data
const selectedSubscriptionId = data.subscription_id
const handleConfigure = useCallback(async () => {
if (!provider) return
try {
const response = await initiateTriggerOAuth.mutateAsync(provider)
if (response.authorization_url) {
openOAuthPopup(response.authorization_url, (callbackData) => {
invalidateSubscriptions(provider)
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: `Failed to configure authentication: ${error.message}`,
})
}
}, [provider, initiateTriggerOAuth, invalidateSubscriptions, notify])
const handleRemove = useCallback(async (subscriptionId: string) => {
if (!subscriptionId) return
try {
await deleteSubscription.mutateAsync(subscriptionId)
// Clear subscription_id from node data
if (onSubscriptionChange)
onSubscriptionChange('')
// Refresh subscriptions list
invalidateSubscriptions(provider)
notify({
type: 'success',
message: t('workflow.nodes.triggerPlugin.subscriptionRemoved'),
})
}
catch (error: any) {
notify({
type: 'error',
message: `Failed to remove subscription: ${error.message}`,
})
}
}, [deleteSubscription, invalidateSubscriptions, notify, onSubscriptionChange, provider, t])
const handleSubscriptionSelect = useCallback((subscriptionId: string) => {
if (onSubscriptionChange)
onSubscriptionChange(subscriptionId)
}, [onSubscriptionChange])
// Tool authentication
if (data.type === BlockEnum.Tool && currCollection?.allow_delete) {
return (
<AuthorizedInNode
@ -130,20 +32,17 @@ const NodeAuth: FC<NodeAuthProps> = ({ data, onAuthorizationChange, onSubscripti
)
}
// Trigger Plugin authentication
if (data.type === BlockEnum.TriggerPlugin) {
return (
<AuthenticationMenu
subscriptions={subscriptions}
selectedSubscriptionId={selectedSubscriptionId}
onSubscriptionSelect={handleSubscriptionSelect}
onConfigure={handleConfigure}
onRemove={handleRemove}
<SubscriptionMenu
// @ts-expect-error TODO: fix this
payload={data}
selectedSubscriptionId={data.subscription_id}
onSubscriptionSelect={({ id, name }) => onSubscriptionChange?.(id, name)}
/>
)
}
// No authentication needed
return null
}

View File

@ -1,209 +0,0 @@
'use client'
import type { FC } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiAddLine, RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import cn from '@/utils/classnames'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
export type AuthenticationStatus = 'authorized' | 'not_configured' | 'error'
export type AuthSubscription = {
id: string
name: string
status: AuthenticationStatus
credentials?: Record<string, any>
}
type AuthenticationMenuProps = {
subscriptions: TriggerSubscription[]
selectedSubscriptionId?: string
onSubscriptionSelect: (subscriptionId: string) => void
onConfigure: () => void
onRemove: (subscriptionId: string) => void
className?: string
}
const AuthenticationMenu: FC<AuthenticationMenuProps> = ({
subscriptions,
selectedSubscriptionId,
onSubscriptionSelect,
onConfigure,
onRemove,
className,
}) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const selectedSubscription = useMemo(() => {
return subscriptions.find(sub => sub.id === selectedSubscriptionId)
}, [subscriptions, selectedSubscriptionId])
const getStatusConfig = useCallback(() => {
if (!selectedSubscription) {
if (subscriptions.length > 0) {
return {
label: t('workflow.nodes.triggerPlugin.selectSubscription'),
color: 'yellow' as const,
}
}
return {
label: t('workflow.nodes.triggerPlugin.notConfigured'),
color: 'red' as const,
}
}
// Check if subscription is authorized based on credential_type
const isAuthorized = selectedSubscription.credential_type !== 'unauthorized'
if (isAuthorized) {
return {
label: selectedSubscription.name || t('workflow.nodes.triggerPlugin.authorized'),
color: 'green' as const,
}
}
else {
return {
label: t('workflow.nodes.triggerPlugin.notAuthorized'),
color: 'red' as const,
}
}
}, [selectedSubscription, subscriptions.length, t])
const statusConfig = getStatusConfig()
const handleConfigure = useCallback(() => {
onConfigure()
setIsOpen(false)
}, [onConfigure])
const handleRemove = useCallback((subscriptionId: string) => {
onRemove(subscriptionId)
setIsOpen(false)
}, [onRemove])
const handleSelectSubscription = useCallback((subscriptionId: string) => {
onSubscriptionSelect(subscriptionId)
setIsOpen(false)
}, [onSubscriptionSelect])
return (
<div className={cn('relative', className)}>
<Button
size='small'
variant='ghost'
className={cn(
'h-6 px-1.5 py-1',
'hover:bg-components-button-ghost-bg-hover',
isOpen && 'bg-components-button-ghost-bg-hover',
)}
onClick={() => setIsOpen(!isOpen)}
>
<Indicator
className='mr-1.5'
color={statusConfig.color}
/>
<span className="text-xs font-medium text-components-button-ghost-text">
{statusConfig.label}
</span>
<RiArrowDownSLine
className='ml-1 h-3.5 w-3.5 text-components-button-ghost-text'
/>
</Button>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-20"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown Menu */}
<div className={cn(
'absolute right-0 z-30 mt-1',
'w-[240px] rounded-xl border-[0.5px] border-components-panel-border',
'bg-components-panel-bg-blur shadow-lg backdrop-blur-sm',
)}>
<div className="py-1">
{/* Subscription list */}
{subscriptions.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-text-tertiary">
{t('workflow.nodes.triggerPlugin.availableSubscriptions')}
</div>
<div className="max-h-[200px] overflow-y-auto">
{subscriptions.map((subscription) => {
const isSelected = subscription.id === selectedSubscriptionId
const isAuthorized = subscription.credential_type !== 'unauthorized'
return (
<button
key={subscription.id}
className={cn(
'flex w-full items-center justify-between px-3 py-2 text-left text-sm',
'hover:bg-state-base-hover',
isSelected && 'bg-state-base-hover',
)}
onClick={() => handleSelectSubscription(subscription.id)}
>
<div className="flex items-center gap-2">
<Indicator
color={isAuthorized ? 'green' : 'red'}
/>
<span className={cn(
'text-text-secondary',
isSelected && 'font-medium text-text-primary',
)}>
{subscription.name}
</span>
</div>
{isSelected && (
<RiCheckLine className="h-4 w-4 text-text-accent" />
)}
</button>
)
})}
</div>
<div className="my-1 h-[0.5px] bg-divider-subtle" />
</>
)}
{/* Add new subscription */}
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm',
'text-text-secondary hover:bg-state-base-hover',
)}
onClick={handleConfigure}
>
<RiAddLine className="h-4 w-4" />
{t('workflow.nodes.triggerPlugin.addSubscription')}
</button>
{/* Remove subscription */}
{selectedSubscription && (
<>
<div className="my-1 h-[0.5px] bg-divider-subtle" />
<button
className={cn(
'block w-full px-3 py-2 text-left text-sm',
'text-text-destructive hover:bg-state-destructive-hover',
)}
onClick={() => handleRemove(selectedSubscription.id)}
>
{t('workflow.nodes.triggerPlugin.removeSubscription')}
</button>
</>
)}
</div>
</div>
</>
)}
</div>
)
}
export default memo(AuthenticationMenu)

View File

@ -0,0 +1,44 @@
'use client'
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 { memo, useEffect } from 'react'
import type { PluginTriggerNodeType } from '../types'
import useConfig from '../use-config'
export const SubscriptionMenu = memo(({ payload, selectedSubscriptionId, onSubscriptionSelect }: {
payload: PluginTriggerNodeType,
selectedSubscriptionId?: string,
onSubscriptionSelect: ({ id, name }: { id: string, name: string }) => void
}) => {
// @ts-expect-error TODO: fix this
const { currentProvider } = useConfig(payload.id as string, payload)
const { setDetail } = usePluginStore()
useEffect(() => {
if (currentProvider) {
setDetail({
plugin_id: currentProvider.plugin_id || '',
provider: currentProvider.name,
declaration: {
tool: undefined,
endpoint: undefined,
trigger: {
subscription_schema: currentProvider.subscription_schema,
credentials_schema: currentProvider.credentials_schema,
oauth_schema: {
client_schema: currentProvider.oauth_client_schema,
},
},
},
})
}
}, [currentProvider, setDetail])
return (
<SubscriptionSelectorEntry
selectedId={selectedSubscriptionId}
onSelect={onSubscriptionSelect}
/>
)
})