diff --git a/web/app/components/base/error-boundary/index.tsx b/web/app/components/base/error-boundary/index.tsx new file mode 100644 index 0000000000..e3df2c2ca8 --- /dev/null +++ b/web/app/components/base/error-boundary/index.tsx @@ -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 + 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) => void + }, + ErrorBoundaryState +> { + constructor(props: any) { + super(props) + this.state = { + hasError: false, + error: null, + errorInfo: null, + errorCount: 0, + } + } + + static getDerivedStateFromError(error: Error): Partial { + 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 ( +
+
+ +

+ {customTitle || 'Something went wrong'} +

+
+ +

+ {customMessage || 'An unexpected error occurred while rendering this component.'} +

+ + {showDetails && errorInfo && ( +
+ + + + Error Details (Development Only) + + +
+
+ Error: +
+                    {error.toString()}
+                  
+
+ {errorInfo && ( +
+ Component Stack: +
+                      {errorInfo.componentStack}
+                    
+
+ )} + {errorCount > 1 && ( +
+ This error has occurred {errorCount} times +
+ )} +
+
+ )} + + {enableRecovery && ( +
+ + +
+ )} +
+ ) + } + + return children + } +} + +// Main functional component wrapper +const ErrorBoundary: React.FC = (props) => { + const [errorBoundaryKey, setErrorBoundaryKey] = useState(0) + const resetKeysRef = useRef(props.resetKeys) + const prevResetKeysRef = useRef | undefined>(undefined) + + const resetErrorBoundary = useCallback(() => { + setErrorBoundaryKey(prev => prev + 1) + props.onReset?.() + }, [props]) + + const onResetKeysChange = useCallback((prevResetKeys?: Array) => { + prevResetKeysRef.current = prevResetKeys + }, []) + + useEffect(() => { + if (prevResetKeysRef.current !== props.resetKeys) + resetKeysRef.current = props.resetKeys + }, [props.resetKeys]) + + return ( + + ) +} + +// Hook for imperative error handling +export function useErrorHandler() { + const [error, setError] = useState(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

( + Component: React.ComponentType

, + errorBoundaryProps?: Omit, +): React.ComponentType

{ + const WrappedComponent = (props: P) => ( + + + + ) + + 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 ( +

+

Oops! Something went wrong

+

{error.message}

+ +
+ ) +} + +export default ErrorBoundary diff --git a/web/app/components/plugins/plugin-detail-panel/index.tsx b/web/app/components/plugins/plugin-detail-panel/index.tsx index 9ac2c4f538..8f8c2b58e7 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/index.tsx @@ -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 = ({ 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) diff --git a/web/app/components/plugins/plugin-detail-panel/store.ts b/web/app/components/plugins/plugin-detail-panel/store.ts deleted file mode 100644 index 93e5ea634a..0000000000 --- a/web/app/components/plugins/plugin-detail-panel/store.ts +++ /dev/null @@ -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(set => ({ - detail: undefined, - setDetail: (detail: PluginDetail) => set({ detail }), -})) - -type ShapeSubscription = { - refresh?: () => void - setRefresh: (refresh: () => void) => void -} - -export const usePluginSubscriptionStore = create(set => ({ - refresh: undefined, - setRefresh: (refresh: () => void) => set({ refresh }), -})) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx index be37effd6a..0e7c6c9562 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx @@ -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(null) const propertiesFormRef = React.useRef(null) @@ -82,7 +81,7 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => { const credentialsFormRef = React.useRef(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) => { */} {createType !== SupportedCreationMethods.MANUAL && parametersSchema.length > 0 && ( ({ + 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) => {
- Awaiting request from {detail?.declaration.name}... + Awaiting request from {detail?.plugin_id}...
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx index 88dadb8300..81b76539ab 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx @@ -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) { diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx index bff06b6ff9..0b5f553e13 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx @@ -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 diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx new file mode 100644 index 0000000000..a6b7f7c07b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx @@ -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 onClose(false)} + /> +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx index 4d1c725b7e..6929bc93e4 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx @@ -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 ( -
-
-
{t('common.dataLoading')}
-
-
+ ) } - const hasSubscriptions = subscriptions && subscriptions.length > 0 + // const showTopBorder = !!(detail?.declaration?.tool || detail?.declaration?.endpoint) return ( -
-
- { - hasSubscriptions - &&
- - {t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })} - - -
- } - -
- - {hasSubscriptions - &&
- {subscriptions?.map(subscription => ( - - ))} -
} -
+ ) -} +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx new file mode 100644 index 0000000000..1fe106ca41 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx @@ -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 = ({ + subscriptions, + isLoading, + showTopBorder = false, + hasSubscriptions, +}) => { + const { t } = useTranslation() + + if (isLoading) { + return ( +
+
+
{t('common.dataLoading')}
+
+
+ ) + } + + return ( +
+
+ {hasSubscriptions && ( +
+ + {t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })} + + +
+ )} + +
+ + {hasSubscriptions && ( +
+ {subscriptions?.map(subscription => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx new file mode 100644 index 0000000000..9530f68326 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-entry.tsx @@ -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 = ({ + 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 ( + + ) +} + +export const SubscriptionSelectorEntry = ({ selectedId, onSelect }: { + selectedId?: string, + onSelect: ({ id, name }: { id: string, name: string }) => void +}) => { + const [isOpen, setIsOpen] = useState(false) + + return + +
+ setIsOpen(!isOpen)} + isOpen={isOpen} + /> +
+
+ +
+ +
+
+
+} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx new file mode 100644 index 0000000000..ade0dad2b9 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/selector-view.tsx @@ -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 = ({ + subscriptions, + isLoading, + hasSubscriptions, + selectedId, + onSelect, +}) => { + const { t } = useTranslation() + const [deletedSubscription, setDeletedSubscription] = useState(null) + + if (isLoading) { + return ( +
+
{t('common.dataLoading')}
+
+ ) + } + + return ( +
+ {hasSubscriptions &&
+
+ + {t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })} + + +
+ +
} +
+ {hasSubscriptions ? ( + <> + {subscriptions?.map(subscription => ( + + ))} + + ) : ( + // todo: refactor this +
+
+ {t('pluginTrigger.subscription.empty.description')} +
+ +
+ )} +
+ {deletedSubscription && ( + { + if (deleted) + onSelect?.({ id: '', name: '' }) + setDeletedSubscription(null) + }} + isShow={!!deletedSubscription} + currentId={deletedSubscription.id} + currentName={deletedSubscription.name} + /> + )} +
+ ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts new file mode 100644 index 0000000000..c98eb7baae --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/store.ts @@ -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(set => ({ + detail: undefined, + setDetail: (detail: SubscriptionListDetail) => set({ detail }), +})) + +type ShapeSubscription = { + refresh?: () => void + setRefresh: (refresh: () => void) => void +} + +export const usePluginSubscriptionStore = create(set => ({ + refresh: undefined, + setRefresh: (refresh: () => void) => set({ refresh }), +})) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx index b68a67efb6..8c1afdbdef 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx @@ -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) => { {isShowDeleteModal && ( - )} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-modal.tsx deleted file mode 100644 index 4069016c85..0000000000 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-modal.tsx +++ /dev/null @@ -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(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 ( - -
-

- {t('plugin.detailPanel.createSubscription')} -

- -
- -
-

- {t('plugin.detailPanel.createSubscriptionDesc')} -

-
- -
-
- - - - - -
-
-
- ) - } - - return ( - -
-
- -

- {selectedMode === 'api-key' && t('plugin.detailPanel.createViaApiKey')} - {selectedMode === 'oauth' && t('plugin.detailPanel.createViaOAuth')} - {selectedMode === 'manual' && t('plugin.detailPanel.createManual')} -

-
- -
- -
-
-
- - setSubscriptionName(e.target.value)} - placeholder={t('plugin.detailPanel.subscriptionNamePlaceholder')} - className='w-full' - /> -
- - {selectedMode === 'api-key' && ( -
- - setApiKey(e.target.value)} - placeholder={t('plugin.detailPanel.apiKeyPlaceholder')} - className='w-full' - /> -
- )} - - {selectedMode === 'oauth' && ( -
-

- {t('plugin.detailPanel.oauthCreateNote')} -

-
- )} - - {selectedMode === 'manual' && ( -
- - setWebhookUrl(e.target.value)} - placeholder={t('plugin.detailPanel.webhookUrlPlaceholder')} - className='w-full' - /> -
- )} -
- -
- - -
-
-
- ) -} - -export default SubscriptionModal diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts new file mode 100644 index 0000000000..504d398412 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/use-subscription-list.ts @@ -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), + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/trigger-events-list.tsx b/web/app/components/plugins/plugin-detail-panel/trigger-events-list.tsx index c6fc510be5..07092d708a 100644 --- a/web/app/components/plugins/plugin-detail-panel/trigger-events-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/trigger-events-list.tsx @@ -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 || [] diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index ba65217949..a2d88f711b 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -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 = ({ 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 = ({ ) } - { + {/* { shouldShowTriggerAuthSelector && ( ) - } + } */} { shouldShowTriggerTab && (
diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/node-auth-factory.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/node-auth-factory.tsx index 0a534f431c..fb296160df 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/node-auth-factory.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/node-auth-factory.tsx @@ -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 = ({ 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 ( = ({ data, onAuthorizationChange, onSubscripti ) } - // Trigger Plugin authentication if (data.type === BlockEnum.TriggerPlugin) { return ( - onSubscriptionChange?.(id, name)} /> ) } - // No authentication needed return null } diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/authentication-menu.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/authentication-menu.tsx deleted file mode 100644 index 71b8e6fa06..0000000000 --- a/web/app/components/workflow/nodes/trigger-plugin/components/authentication-menu.tsx +++ /dev/null @@ -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 -} - -type AuthenticationMenuProps = { - subscriptions: TriggerSubscription[] - selectedSubscriptionId?: string - onSubscriptionSelect: (subscriptionId: string) => void - onConfigure: () => void - onRemove: (subscriptionId: string) => void - className?: string -} - -const AuthenticationMenu: FC = ({ - 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 ( -
- - - {isOpen && ( - <> - {/* Backdrop */} -
setIsOpen(false)} - /> - - {/* Dropdown Menu */} -
-
- {/* Subscription list */} - {subscriptions.length > 0 && ( - <> -
- {t('workflow.nodes.triggerPlugin.availableSubscriptions')} -
-
- {subscriptions.map((subscription) => { - const isSelected = subscription.id === selectedSubscriptionId - const isAuthorized = subscription.credential_type !== 'unauthorized' - return ( - - ) - })} -
-
- - )} - - {/* Add new subscription */} - - - {/* Remove subscription */} - {selectedSubscription && ( - <> -
- - - )} -
-
- - )} -
- ) -} - -export default memo(AuthenticationMenu) diff --git a/web/app/components/workflow/nodes/trigger-plugin/components/subscription-menu.tsx b/web/app/components/workflow/nodes/trigger-plugin/components/subscription-menu.tsx new file mode 100644 index 0000000000..7c0a10a7ff --- /dev/null +++ b/web/app/components/workflow/nodes/trigger-plugin/components/subscription-menu.tsx @@ -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 ( + + ) +})