diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx index c7280c7508..8e7affad8e 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx @@ -1,27 +1,19 @@ 'use client' -import type { FileUpload } from '@/app/components/base/features/types' import type { App } from '@/types/app' -import * as React from 'react' -import { useMemo, useRef } from 'react' +import { useRef } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import AppInputsForm from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form' -import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' -import { useAppDetail } from '@/service/use-apps' -import { useFileUploadConfig } from '@/service/use-common' -import { useAppWorkflow } from '@/service/use-workflow' -import { AppModeEnum, Resolution } from '@/types/app' - +import { useAppInputsFormSchema } from '@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema' import { cn } from '@/utils/classnames' type Props = { value?: { app_id: string - inputs: Record + inputs: Record } appDetail: App - onFormChange: (value: Record) => void + onFormChange: (value: Record) => void } const AppInputsPanel = ({ @@ -30,155 +22,33 @@ const AppInputsPanel = ({ onFormChange, }: Props) => { const { t } = useTranslation() - const inputsRef = useRef(value?.inputs || {}) - const isBasicApp = appDetail.mode !== AppModeEnum.ADVANCED_CHAT && appDetail.mode !== AppModeEnum.WORKFLOW - const { data: fileUploadConfig } = useFileUploadConfig() - const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id) - const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow(isBasicApp ? '' : appDetail.id) - const isLoading = isAppLoading || isWorkflowLoading + const inputsRef = useRef>(value?.inputs || {}) - const basicAppFileConfig = useMemo(() => { - let fileConfig: FileUpload - if (isBasicApp) - fileConfig = currentApp?.model_config?.file_upload as FileUpload - else - fileConfig = currentWorkflow?.features?.file_upload as FileUpload - return { - image: { - detail: fileConfig?.image?.detail || Resolution.high, - enabled: !!fileConfig?.image?.enabled, - number_limits: fileConfig?.image?.number_limits || 3, - transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'], - }, - enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled), - allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image], - allowed_file_extensions: fileConfig?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`), - allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods || fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'], - number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3, - } - }, [currentApp?.model_config?.file_upload, currentWorkflow?.features?.file_upload, isBasicApp]) + const { inputFormSchema, isLoading } = useAppInputsFormSchema({ appDetail }) - const inputFormSchema = useMemo(() => { - if (!currentApp) - return [] - let inputFormSchema = [] - if (isBasicApp) { - inputFormSchema = currentApp.model_config?.user_input_form?.filter((item: any) => !item.external_data_tool).map((item: any) => { - if (item.paragraph) { - return { - ...item.paragraph, - type: 'paragraph', - required: false, - } - } - if (item.number) { - return { - ...item.number, - type: 'number', - required: false, - } - } - if (item.checkbox) { - return { - ...item.checkbox, - type: 'checkbox', - required: false, - } - } - if (item.select) { - return { - ...item.select, - type: 'select', - required: false, - } - } - - if (item['file-list']) { - return { - ...item['file-list'], - type: 'file-list', - required: false, - fileUploadConfig, - } - } - - if (item.file) { - return { - ...item.file, - type: 'file', - required: false, - fileUploadConfig, - } - } - - if (item.json_object) { - return { - ...item.json_object, - type: 'json_object', - } - } - - return { - ...item['text-input'], - type: 'text-input', - required: false, - } - }) || [] - } - else { - const startNode = currentWorkflow?.graph?.nodes.find(node => node.data.type === BlockEnum.Start) as any - inputFormSchema = startNode?.data.variables.map((variable: any) => { - if (variable.type === InputVarType.multiFiles) { - return { - ...variable, - required: false, - fileUploadConfig, - } - } - - if (variable.type === InputVarType.singleFile) { - return { - ...variable, - required: false, - fileUploadConfig, - } - } - return { - ...variable, - required: false, - } - }) || [] - } - if ((currentApp.mode === AppModeEnum.COMPLETION || currentApp.mode === AppModeEnum.WORKFLOW) && basicAppFileConfig.enabled) { - inputFormSchema.push({ - label: 'Image Upload', - variable: '#image#', - type: InputVarType.singleFile, - required: false, - ...basicAppFileConfig, - fileUploadConfig, - }) - } - return inputFormSchema || [] - }, [basicAppFileConfig, currentApp, currentWorkflow, fileUploadConfig, isBasicApp]) - - const handleFormChange = (value: Record) => { - inputsRef.current = value - onFormChange(value) + const handleFormChange = (newValue: Record) => { + inputsRef.current = newValue + onFormChange(newValue) } + const hasInputs = inputFormSchema.length > 0 + return (
{isLoading &&
} {!isLoading && ( -
{t('appSelector.params', { ns: 'app' })}
- )} - {!isLoading && !inputFormSchema.length && ( -
-
{t('appSelector.noParams', { ns: 'app' })}
+
+ {t('appSelector.params', { ns: 'app' })}
)} - {!isLoading && !!inputFormSchema.length && ( + {!isLoading && !hasInputs && ( +
+
+ {t('appSelector.noParams', { ns: 'app' })} +
+
+ )} + {!isLoading && hasInputs && (
= { + 'paragraph': 'paragraph', + 'number': 'number', + 'checkbox': 'checkbox', + 'select': 'select', + 'file-list': 'file-list', + 'file': 'file', + 'json_object': 'json_object', +} + +const FILE_INPUT_TYPES = new Set(['file-list', 'file']) + +const WORKFLOW_FILE_VAR_TYPES = new Set([InputVarType.multiFiles, InputVarType.singleFile]) + +type InputSchemaItem = { + label?: string + variable?: string + type: string + required: boolean + fileUploadConfig?: FileUploadConfigResponse + [key: string]: unknown +} + +function isBasicAppMode(mode: string): boolean { + return mode !== AppModeEnum.ADVANCED_CHAT && mode !== AppModeEnum.WORKFLOW +} + +function supportsImageUpload(mode: string): boolean { + return mode === AppModeEnum.COMPLETION || mode === AppModeEnum.WORKFLOW +} + +function buildFileConfig(fileConfig: FileUpload | undefined) { + return { + image: { + detail: fileConfig?.image?.detail || Resolution.high, + enabled: !!fileConfig?.image?.enabled, + number_limits: fileConfig?.image?.number_limits || 3, + transfer_methods: fileConfig?.image?.transfer_methods || ['local_file', 'remote_url'], + }, + enabled: !!(fileConfig?.enabled || fileConfig?.image?.enabled), + allowed_file_types: fileConfig?.allowed_file_types || [SupportUploadFileTypes.image], + allowed_file_extensions: fileConfig?.allowed_file_extensions + || [...FILE_EXTS[SupportUploadFileTypes.image]].map(ext => `.${ext}`), + allowed_file_upload_methods: fileConfig?.allowed_file_upload_methods + || fileConfig?.image?.transfer_methods + || ['local_file', 'remote_url'], + number_limits: fileConfig?.number_limits || fileConfig?.image?.number_limits || 3, + } +} + +function mapBasicAppInputItem( + item: Record, + fileUploadConfig?: FileUploadConfigResponse, +): InputSchemaItem | null { + for (const [key, type] of Object.entries(BASIC_INPUT_TYPE_MAP)) { + if (!item[key]) + continue + + const inputData = item[key] as Record + const needsFileConfig = FILE_INPUT_TYPES.has(key) + + return { + ...inputData, + type, + required: false, + ...(needsFileConfig && { fileUploadConfig }), + } + } + + const textInput = item['text-input'] as Record | undefined + if (!textInput) + return null + + return { + ...textInput, + type: 'text-input', + required: false, + } +} + +function mapWorkflowVariable( + variable: Record, + fileUploadConfig?: FileUploadConfigResponse, +): InputSchemaItem { + const needsFileConfig = WORKFLOW_FILE_VAR_TYPES.has(variable.type as InputVarType) + + return { + ...variable, + type: variable.type as string, + required: false, + ...(needsFileConfig && { fileUploadConfig }), + } +} + +function createImageUploadSchema( + basicFileConfig: ReturnType, + fileUploadConfig?: FileUploadConfigResponse, +): InputSchemaItem { + return { + label: 'Image Upload', + variable: '#image#', + type: InputVarType.singleFile, + required: false, + ...basicFileConfig, + fileUploadConfig, + } +} + +function buildBasicAppSchema( + currentApp: App, + fileUploadConfig?: FileUploadConfigResponse, +): InputSchemaItem[] { + const userInputForm = currentApp.model_config?.user_input_form as Array> | undefined + if (!userInputForm) + return [] + + return userInputForm + .filter((item: Record) => !item.external_data_tool) + .map((item: Record) => mapBasicAppInputItem(item, fileUploadConfig)) + .filter((item): item is InputSchemaItem => item !== null) +} + +function buildWorkflowSchema( + workflow: FetchWorkflowDraftResponse, + fileUploadConfig?: FileUploadConfigResponse, +): InputSchemaItem[] { + const startNode = workflow.graph?.nodes.find( + node => node.data.type === BlockEnum.Start, + ) as { data: { variables: Array> } } | undefined + + if (!startNode?.data.variables) + return [] + + return startNode.data.variables.map( + variable => mapWorkflowVariable(variable, fileUploadConfig), + ) +} + +type UseAppInputsFormSchemaParams = { + appDetail: App +} + +type UseAppInputsFormSchemaResult = { + inputFormSchema: InputSchemaItem[] + isLoading: boolean + fileUploadConfig?: FileUploadConfigResponse +} + +export function useAppInputsFormSchema({ + appDetail, +}: UseAppInputsFormSchemaParams): UseAppInputsFormSchemaResult { + const isBasicApp = isBasicAppMode(appDetail.mode) + + const { data: fileUploadConfig } = useFileUploadConfig() + const { data: currentApp, isFetching: isAppLoading } = useAppDetail(appDetail.id) + const { data: currentWorkflow, isFetching: isWorkflowLoading } = useAppWorkflow( + isBasicApp ? '' : appDetail.id, + ) + + const isLoading = isAppLoading || isWorkflowLoading + + const inputFormSchema = useMemo(() => { + if (!currentApp) + return [] + + if (!isBasicApp && !currentWorkflow) + return [] + + // Build base schema based on app type + // Note: currentWorkflow is guaranteed to be defined here due to the early return above + const baseSchema = isBasicApp + ? buildBasicAppSchema(currentApp, fileUploadConfig) + : buildWorkflowSchema(currentWorkflow!, fileUploadConfig) + + if (!supportsImageUpload(currentApp.mode)) + return baseSchema + + const rawFileConfig = isBasicApp + ? currentApp.model_config?.file_upload as FileUpload + : currentWorkflow?.features?.file_upload as FileUpload + + const basicFileConfig = buildFileConfig(rawFileConfig) + + if (!basicFileConfig.enabled) + return baseSchema + + return [ + ...baseSchema, + createImageUploadSchema(basicFileConfig, fileUploadConfig), + ] + }, [currentApp, currentWorkflow, fileUploadConfig, isBasicApp]) + + return { + inputFormSchema, + isLoading, + fileUploadConfig, + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx index 49c3ef1058..cc0ac404b2 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.spec.tsx @@ -6,7 +6,6 @@ import Toast from '@/app/components/base/toast' import { PluginSource } from '../types' import DetailHeader from './detail-header' -// Use vi.hoisted for mock functions used in vi.mock factories const { mockSetShowUpdatePluginModal, mockRefreshModelProviders, diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index 7f7e11ad51..3f39ed289e 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -1,416 +1,2 @@ -import type { PluginDetail } from '../types' -import { - RiArrowLeftRightLine, - RiBugLine, - RiCloseLine, - RiHardDrive3Line, -} from '@remixicon/react' -import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import ActionButton from '@/app/components/base/action-button' -import { trackEvent } from '@/app/components/base/amplitude' -import Badge from '@/app/components/base/badge' -import Button from '@/app/components/base/button' -import Confirm from '@/app/components/base/confirm' -import { Github } from '@/app/components/base/icons/src/public/common' -import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' -import Toast from '@/app/components/base/toast' -import Tooltip from '@/app/components/base/tooltip' -import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth' -import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' -import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' -import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' -import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' -import { API_PREFIX } from '@/config' -import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { useGetLanguage, useLocale } from '@/context/i18n' -import { useModalContext } from '@/context/modal-context' -import { useProviderContext } from '@/context/provider-context' -import useTheme from '@/hooks/use-theme' -import { uninstallPlugin } from '@/service/plugins' -import { useAllToolProviders, useInvalidateAllToolProviders } from '@/service/use-tools' -import { cn } from '@/utils/classnames' -import { getMarketplaceUrl } from '@/utils/var' -import { AutoUpdateLine } from '../../base/icons/src/vender/system' -import Verified from '../base/badges/verified' -import DeprecationNotice from '../base/deprecation-notice' -import Icon from '../card/base/card-icon' -import Description from '../card/base/description' -import OrgInfo from '../card/base/org-info' -import Title from '../card/base/title' -import { useGitHubReleases } from '../install-plugin/hooks' -import useReferenceSetting from '../plugin-page/use-reference-setting' -import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types' -import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils' -import { PluginCategoryEnum, PluginSource } from '../types' - -const i18nPrefix = 'action' - -type Props = { - detail: PluginDetail - isReadmeView?: boolean - onHide?: () => void - onUpdate?: (isDelete?: boolean) => void -} - -const DetailHeader = ({ - detail, - isReadmeView = false, - onHide, - onUpdate, -}: Props) => { - const { t } = useTranslation() - const { userProfile: { timezone } } = useAppContext() - - const { theme } = useTheme() - const locale = useGetLanguage() - const currentLocale = useLocale() - const { checkForUpdates, fetchReleases } = useGitHubReleases() - const { setShowUpdatePluginModal } = useModalContext() - const { refreshModelProviders } = useProviderContext() - const invalidateAllToolProviders = useInvalidateAllToolProviders() - const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) - - const { - id, - source, - tenant_id, - version, - latest_unique_identifier, - latest_version, - meta, - plugin_id, - status, - deprecated_reason, - alternative_plugin_id, - } = detail - - const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail - const isTool = category === PluginCategoryEnum.tool - const providerBriefInfo = tool?.identity - const providerKey = `${plugin_id}/${providerBriefInfo?.name}` - const { data: collectionList = [] } = useAllToolProviders(isTool) - const provider = useMemo(() => { - return collectionList.find(collection => collection.name === providerKey) - }, [collectionList, providerKey]) - const isFromGitHub = source === PluginSource.github - const isFromMarketplace = source === PluginSource.marketplace - - const [isShow, setIsShow] = useState(false) - const [targetVersion, setTargetVersion] = useState({ - version: latest_version, - unique_identifier: latest_unique_identifier, - }) - const hasNewVersion = useMemo(() => { - if (isFromMarketplace) - return !!latest_version && latest_version !== version - - return false - }, [isFromMarketplace, latest_version, version]) - - const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon - const iconSrc = iconFileName - ? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`) - : '' - - const detailUrl = useMemo(() => { - if (isFromGitHub) - return `https://github.com/${meta!.repo}` - if (isFromMarketplace) - return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme }) - return '' - }, [author, isFromGitHub, isFromMarketplace, meta, name, theme]) - - const [isShowUpdateModal, { - setTrue: showUpdateModal, - setFalse: hideUpdateModal, - }] = useBoolean(false) - - const { referenceSetting } = useReferenceSetting() - const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {} - const isAutoUpgradeEnabled = useMemo(() => { - if (!enable_marketplace) - return false - if (!autoUpgradeInfo || !isFromMarketplace) - return false - if (autoUpgradeInfo.strategy_setting === 'disabled') - return false - if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all) - return true - if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id)) - return true - if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id)) - return true - return false - }, [autoUpgradeInfo, plugin_id, isFromMarketplace]) - - const [isDowngrade, setIsDowngrade] = useState(false) - const handleUpdate = async (isDowngrade?: boolean) => { - if (isFromMarketplace) { - setIsDowngrade(!!isDowngrade) - showUpdateModal() - return - } - - const owner = meta!.repo.split('/')[0] || author - const repo = meta!.repo.split('/')[1] || name - const fetchedReleases = await fetchReleases(owner, repo) - if (fetchedReleases.length === 0) - return - const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta!.version) - Toast.notify(toastProps) - if (needUpdate) { - setShowUpdatePluginModal({ - onSaveCallback: () => { - onUpdate?.() - }, - payload: { - type: PluginSource.github, - category: detail.declaration.category, - github: { - originalPackageInfo: { - id: detail.plugin_unique_identifier, - repo: meta!.repo, - version: meta!.version, - package: meta!.package, - releases: fetchedReleases, - }, - }, - }, - }) - } - } - - const handleUpdatedFromMarketplace = () => { - onUpdate?.() - hideUpdateModal() - } - - const [isShowPluginInfo, { - setTrue: showPluginInfo, - setFalse: hidePluginInfo, - }] = useBoolean(false) - - const [isShowDeleteConfirm, { - setTrue: showDeleteConfirm, - setFalse: hideDeleteConfirm, - }] = useBoolean(false) - - const [deleting, { - setTrue: showDeleting, - setFalse: hideDeleting, - }] = useBoolean(false) - - const handleDelete = useCallback(async () => { - showDeleting() - const res = await uninstallPlugin(id) - hideDeleting() - if (res.success) { - hideDeleteConfirm() - onUpdate?.(true) - if (PluginCategoryEnum.model.includes(category)) - refreshModelProviders() - if (PluginCategoryEnum.tool.includes(category)) - invalidateAllToolProviders() - trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name }) - } - }, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders, plugin_id, name]) - - return ( -
-
-
- -
-
-
- - {verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />} - {!!version && ( - <PluginVersionPicker - disabled={!isFromMarketplace || isReadmeView} - isShow={isShow} - onShowChange={setIsShow} - pluginID={plugin_id} - currentVersion={version} - onSelect={(state) => { - setTargetVersion(state) - handleUpdate(state.isDowngrade) - }} - trigger={( - <Badge - className={cn( - 'mx-1', - isShow && 'bg-state-base-hover', - (isShow || isFromMarketplace) && 'hover:bg-state-base-hover', - )} - uppercase={false} - text={( - <> - <div>{isFromGitHub ? meta!.version : version}</div> - {isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />} - </> - )} - hasRedCornerMark={hasNewVersion} - /> - )} - /> - )} - {/* Auto update info */} - {isAutoUpgradeEnabled && !isReadmeView && ( - <Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}> - {/* add a a div to fix tooltip hover not show problem */} - <div> - <Badge className="mr-1 cursor-pointer px-1"> - <AutoUpdateLine className="size-3" /> - </Badge> - </div> - </Tooltip> - )} - - {(hasNewVersion || isFromGitHub) && ( - <Button - variant="secondary-accent" - size="small" - className="!h-5" - onClick={() => { - if (isFromMarketplace) { - setTargetVersion({ - version: latest_version, - unique_identifier: latest_unique_identifier, - }) - } - handleUpdate() - }} - > - {t('detailPanel.operation.update', { ns: 'plugin' })} - </Button> - )} - </div> - <div className="mb-1 flex h-4 items-center justify-between"> - <div className="mt-0.5 flex items-center"> - <OrgInfo - packageNameClassName="w-auto" - orgName={author} - packageName={name?.includes('/') ? (name.split('/').pop() || '') : name} - /> - {!!source && ( - <> - <div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div> - {source === PluginSource.marketplace && ( - <Tooltip popupContent={t('detailPanel.categoryTip.marketplace', { ns: 'plugin' })}> - <div><BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" /></div> - </Tooltip> - )} - {source === PluginSource.github && ( - <Tooltip popupContent={t('detailPanel.categoryTip.github', { ns: 'plugin' })}> - <div><Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" /></div> - </Tooltip> - )} - {source === PluginSource.local && ( - <Tooltip popupContent={t('detailPanel.categoryTip.local', { ns: 'plugin' })}> - <div><RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" /></div> - </Tooltip> - )} - {source === PluginSource.debugging && ( - <Tooltip popupContent={t('detailPanel.categoryTip.debugging', { ns: 'plugin' })}> - <div><RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" /></div> - </Tooltip> - )} - </> - )} - </div> - </div> - </div> - {!isReadmeView && ( - <div className="flex gap-1"> - <OperationDropdown - source={source} - onInfo={showPluginInfo} - onCheckVersion={handleUpdate} - onRemove={showDeleteConfirm} - detailUrl={detailUrl} - /> - <ActionButton onClick={onHide}> - <RiCloseLine className="h-4 w-4" /> - </ActionButton> - </div> - )} - </div> - {isFromMarketplace && ( - <DeprecationNotice - status={status} - deprecatedReason={deprecated_reason} - alternativePluginId={alternative_plugin_id} - alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })} - className="mt-3" - /> - )} - {!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2}></Description>} - { - category === PluginCategoryEnum.tool && !isReadmeView && ( - <PluginAuth - pluginPayload={{ - provider: provider?.name || '', - category: AuthCategory.tool, - providerType: provider?.type || '', - detail, - }} - /> - ) - } - {isShowPluginInfo && ( - <PluginInfo - repository={isFromGitHub ? meta?.repo : ''} - release={version} - packageName={meta?.package || ''} - onHide={hidePluginInfo} - /> - )} - {isShowDeleteConfirm && ( - <Confirm - isShow - title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })} - content={( - <div> - {t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })} - <span className="system-md-semibold">{label[locale]}</span> - {t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })} - <br /> - </div> - )} - onCancel={hideDeleteConfirm} - onConfirm={handleDelete} - isLoading={deleting} - isDisabled={deleting} - /> - )} - { - isShowUpdateModal && ( - <UpdateFromMarketplace - pluginId={plugin_id} - payload={{ - category: detail.declaration.category, - originalPackageInfo: { - id: detail.plugin_unique_identifier, - payload: detail.declaration, - }, - targetPackageInfo: { - id: targetVersion.unique_identifier, - version: targetVersion.version, - }, - }} - onCancel={hideUpdateModal} - onSave={handleUpdatedFromMarketplace} - isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled} - /> - ) - } - </div> - ) -} - -export default DetailHeader +// Re-export from refactored module for backward compatibility +export { default } from './detail-header/index' diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx new file mode 100644 index 0000000000..4011ee13f5 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.spec.tsx @@ -0,0 +1,539 @@ +import type { PluginDetail } from '../../../types' +import type { ModalStates, VersionTarget } from '../hooks' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginSource } from '../../../types' +import HeaderModals from './header-modals' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, title, onCancel, onConfirm, isLoading }: { + isShow: boolean + title: string + onCancel: () => void + onConfirm: () => void + isLoading: boolean + }) => isShow + ? ( + <div data-testid="delete-confirm"> + <div data-testid="delete-title">{title}</div> + <button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button> + <button data-testid="confirm-ok" onClick={onConfirm} disabled={isLoading}>Confirm</button> + </div> + ) + : null, +})) + +vi.mock('@/app/components/plugins/plugin-page/plugin-info', () => ({ + default: ({ repository, release, packageName, onHide }: { + repository: string + release: string + packageName: string + onHide: () => void + }) => ( + <div data-testid="plugin-info"> + <div data-testid="plugin-info-repo">{repository}</div> + <div data-testid="plugin-info-release">{release}</div> + <div data-testid="plugin-info-package">{packageName}</div> + <button data-testid="plugin-info-close" onClick={onHide}>Close</button> + </div> + ), +})) + +vi.mock('@/app/components/plugins/update-plugin/from-market-place', () => ({ + default: ({ pluginId, onSave, onCancel, isShowDowngradeWarningModal }: { + pluginId: string + onSave: () => void + onCancel: () => void + isShowDowngradeWarningModal: boolean + }) => ( + <div data-testid="update-modal"> + <div data-testid="update-plugin-id">{pluginId}</div> + <div data-testid="update-downgrade-warning">{String(isShowDowngradeWarningModal)}</div> + <button data-testid="update-modal-save" onClick={onSave}>Save</button> + <button data-testid="update-modal-cancel" onClick={onCancel}>Cancel</button> + </div> + ), +})) + +const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + author: 'test-author', + name: 'test-plugin-name', + category: 'tool', + label: { en_US: 'Test Plugin Label' }, + description: { en_US: 'Test description' }, + icon: 'icon.png', + verified: true, + } as unknown as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '2.0.0', + latest_unique_identifier: 'new-uid', + source: PluginSource.marketplace, + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +const createModalStatesMock = (overrides: Partial<ModalStates> = {}): ModalStates => ({ + isShowUpdateModal: false, + showUpdateModal: vi.fn<() => void>(), + hideUpdateModal: vi.fn<() => void>(), + isShowPluginInfo: false, + showPluginInfo: vi.fn<() => void>(), + hidePluginInfo: vi.fn<() => void>(), + isShowDeleteConfirm: false, + showDeleteConfirm: vi.fn<() => void>(), + hideDeleteConfirm: vi.fn<() => void>(), + deleting: false, + showDeleting: vi.fn<() => void>(), + hideDeleting: vi.fn<() => void>(), + ...overrides, +}) + +const createTargetVersion = (overrides: Partial<VersionTarget> = {}): VersionTarget => ({ + version: '2.0.0', + unique_identifier: 'new-uid', + ...overrides, +}) + +describe('HeaderModals', () => { + let mockOnUpdatedFromMarketplace: () => void + let mockOnDelete: () => void + + beforeEach(() => { + vi.clearAllMocks() + mockOnUpdatedFromMarketplace = vi.fn<() => void>() + mockOnDelete = vi.fn<() => void>() + }) + + describe('Plugin Info Modal', () => { + it('should not render plugin info modal when isShowPluginInfo is false', () => { + const modalStates = createModalStatesMock({ isShowPluginInfo: false }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.queryByTestId('plugin-info')).not.toBeInTheDocument() + }) + + it('should render plugin info modal when isShowPluginInfo is true', () => { + const modalStates = createModalStatesMock({ isShowPluginInfo: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('plugin-info')).toBeInTheDocument() + }) + + it('should pass GitHub repo to plugin info for GitHub source', () => { + const modalStates = createModalStatesMock({ isShowPluginInfo: true }) + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'test-pkg' }, + }) + render( + <HeaderModals + detail={detail} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('owner/repo') + }) + + it('should pass empty string for repo for non-GitHub source', () => { + const modalStates = createModalStatesMock({ isShowPluginInfo: true }) + render( + <HeaderModals + detail={createPluginDetail({ source: PluginSource.marketplace })} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('') + }) + + it('should call hidePluginInfo when close button is clicked', () => { + const modalStates = createModalStatesMock({ isShowPluginInfo: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + fireEvent.click(screen.getByTestId('plugin-info-close')) + + expect(modalStates.hidePluginInfo).toHaveBeenCalled() + }) + }) + + describe('Delete Confirm Modal', () => { + it('should not render delete confirm when isShowDeleteConfirm is false', () => { + const modalStates = createModalStatesMock({ isShowDeleteConfirm: false }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.queryByTestId('delete-confirm')).not.toBeInTheDocument() + }) + + it('should render delete confirm when isShowDeleteConfirm is true', () => { + const modalStates = createModalStatesMock({ isShowDeleteConfirm: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + }) + + it('should show correct delete title', () => { + const modalStates = createModalStatesMock({ isShowDeleteConfirm: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('delete-title')).toHaveTextContent('action.delete') + }) + + it('should call hideDeleteConfirm when cancel is clicked', () => { + const modalStates = createModalStatesMock({ isShowDeleteConfirm: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + fireEvent.click(screen.getByTestId('confirm-cancel')) + + expect(modalStates.hideDeleteConfirm).toHaveBeenCalled() + }) + + it('should call onDelete when confirm is clicked', () => { + const modalStates = createModalStatesMock({ isShowDeleteConfirm: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + fireEvent.click(screen.getByTestId('confirm-ok')) + + expect(mockOnDelete).toHaveBeenCalled() + }) + + it('should disable confirm button when deleting', () => { + const modalStates = createModalStatesMock({ isShowDeleteConfirm: true, deleting: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('confirm-ok')).toBeDisabled() + }) + }) + + describe('Update Modal', () => { + it('should not render update modal when isShowUpdateModal is false', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: false }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.queryByTestId('update-modal')).not.toBeInTheDocument() + }) + + it('should render update modal when isShowUpdateModal is true', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('update-modal')).toBeInTheDocument() + }) + + it('should pass plugin id to update modal', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: true }) + render( + <HeaderModals + detail={createPluginDetail({ plugin_id: 'my-plugin-id' })} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('update-plugin-id')).toHaveTextContent('my-plugin-id') + }) + + it('should call onUpdatedFromMarketplace when save is clicked', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + fireEvent.click(screen.getByTestId('update-modal-save')) + + expect(mockOnUpdatedFromMarketplace).toHaveBeenCalled() + }) + + it('should call hideUpdateModal when cancel is clicked', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + fireEvent.click(screen.getByTestId('update-modal-cancel')) + + expect(modalStates.hideUpdateModal).toHaveBeenCalled() + }) + + it('should show downgrade warning when isDowngrade and isAutoUpgradeEnabled are true', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={true} + isAutoUpgradeEnabled={true} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('true') + }) + + it('should not show downgrade warning when only isDowngrade is true', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={true} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false') + }) + + it('should not show downgrade warning when only isAutoUpgradeEnabled is true', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={true} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('update-downgrade-warning')).toHaveTextContent('false') + }) + }) + + describe('Multiple Modals', () => { + it('should render multiple modals when multiple are open', () => { + const modalStates = createModalStatesMock({ + isShowPluginInfo: true, + isShowDeleteConfirm: true, + isShowUpdateModal: true, + }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('plugin-info')).toBeInTheDocument() + expect(screen.getByTestId('delete-confirm')).toBeInTheDocument() + expect(screen.getByTestId('update-modal')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined target version values', () => { + const modalStates = createModalStatesMock({ isShowUpdateModal: true }) + render( + <HeaderModals + detail={createPluginDetail()} + modalStates={modalStates} + targetVersion={{ version: undefined, unique_identifier: undefined }} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('update-modal')).toBeInTheDocument() + }) + + it('should handle empty meta for GitHub source', () => { + const modalStates = createModalStatesMock({ isShowPluginInfo: true }) + const detail = createPluginDetail({ + source: PluginSource.github, + meta: undefined, + }) + render( + <HeaderModals + detail={detail} + modalStates={modalStates} + targetVersion={createTargetVersion()} + isDowngrade={false} + isAutoUpgradeEnabled={false} + onUpdatedFromMarketplace={mockOnUpdatedFromMarketplace} + onDelete={mockOnDelete} + />, + ) + + expect(screen.getByTestId('plugin-info-repo')).toHaveTextContent('') + expect(screen.getByTestId('plugin-info-package')).toHaveTextContent('') + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx new file mode 100644 index 0000000000..62840b64e3 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/header-modals.tsx @@ -0,0 +1,107 @@ +'use client' + +import type { FC } from 'react' +import type { PluginDetail } from '../../../types' +import type { ModalStates, VersionTarget } from '../hooks' +import { useTranslation } from 'react-i18next' +import Confirm from '@/app/components/base/confirm' +import PluginInfo from '@/app/components/plugins/plugin-page/plugin-info' +import UpdateFromMarketplace from '@/app/components/plugins/update-plugin/from-market-place' +import { useGetLanguage } from '@/context/i18n' +import { PluginSource } from '../../../types' + +const i18nPrefix = 'action' + +type HeaderModalsProps = { + detail: PluginDetail + modalStates: ModalStates + targetVersion: VersionTarget + isDowngrade: boolean + isAutoUpgradeEnabled: boolean + onUpdatedFromMarketplace: () => void + onDelete: () => void +} + +const HeaderModals: FC<HeaderModalsProps> = ({ + detail, + modalStates, + targetVersion, + isDowngrade, + isAutoUpgradeEnabled, + onUpdatedFromMarketplace, + onDelete, +}) => { + const { t } = useTranslation() + const locale = useGetLanguage() + + const { source, version, meta } = detail + const { label } = detail.declaration || detail + const isFromGitHub = source === PluginSource.github + + const { + isShowUpdateModal, + hideUpdateModal, + isShowPluginInfo, + hidePluginInfo, + isShowDeleteConfirm, + hideDeleteConfirm, + deleting, + } = modalStates + + return ( + <> + {/* Plugin Info Modal */} + {isShowPluginInfo && ( + <PluginInfo + repository={isFromGitHub ? meta?.repo : ''} + release={version} + packageName={meta?.package || ''} + onHide={hidePluginInfo} + /> + )} + + {/* Delete Confirm Modal */} + {isShowDeleteConfirm && ( + <Confirm + isShow + title={t(`${i18nPrefix}.delete`, { ns: 'plugin' })} + content={( + <div> + {t(`${i18nPrefix}.deleteContentLeft`, { ns: 'plugin' })} + <span className="system-md-semibold">{label[locale]}</span> + {t(`${i18nPrefix}.deleteContentRight`, { ns: 'plugin' })} + <br /> + </div> + )} + onCancel={hideDeleteConfirm} + onConfirm={onDelete} + isLoading={deleting} + isDisabled={deleting} + /> + )} + + {/* Update from Marketplace Modal */} + {isShowUpdateModal && ( + <UpdateFromMarketplace + pluginId={detail.plugin_id} + payload={{ + category: detail.declaration?.category ?? '', + originalPackageInfo: { + id: detail.plugin_unique_identifier, + payload: detail.declaration ?? undefined, + }, + targetPackageInfo: { + id: targetVersion.unique_identifier || '', + version: targetVersion.version || '', + }, + }} + onCancel={hideUpdateModal} + onSave={onUpdatedFromMarketplace} + isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled} + /> + )} + </> + ) +} + +export default HeaderModals diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/index.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/components/index.ts new file mode 100644 index 0000000000..6e0d9d5042 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/index.ts @@ -0,0 +1,2 @@ +export { default as HeaderModals } from './header-modals' +export { default as PluginSourceBadge } from './plugin-source-badge' diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx new file mode 100644 index 0000000000..e2fa1f6140 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.spec.tsx @@ -0,0 +1,200 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginSource } from '../../../types' +import PluginSourceBadge from './plugin-source-badge' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( + <div data-testid="tooltip" data-content={popupContent}> + {children} + </div> + ), +})) + +describe('PluginSourceBadge', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Source Icon Rendering', () => { + it('should render marketplace source badge', () => { + render(<PluginSourceBadge source={PluginSource.marketplace} />) + + const tooltip = screen.getByTestId('tooltip') + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.marketplace') + }) + + it('should render github source badge', () => { + render(<PluginSourceBadge source={PluginSource.github} />) + + const tooltip = screen.getByTestId('tooltip') + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.github') + }) + + it('should render local source badge', () => { + render(<PluginSourceBadge source={PluginSource.local} />) + + const tooltip = screen.getByTestId('tooltip') + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.local') + }) + + it('should render debugging source badge', () => { + render(<PluginSourceBadge source={PluginSource.debugging} />) + + const tooltip = screen.getByTestId('tooltip') + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute('data-content', 'detailPanel.categoryTip.debugging') + }) + }) + + describe('Separator Rendering', () => { + it('should render separator dot before marketplace badge', () => { + const { container } = render(<PluginSourceBadge source={PluginSource.marketplace} />) + + const separator = container.querySelector('.text-text-quaternary') + expect(separator).toBeInTheDocument() + expect(separator?.textContent).toBe('·') + }) + + it('should render separator dot before github badge', () => { + const { container } = render(<PluginSourceBadge source={PluginSource.github} />) + + const separator = container.querySelector('.text-text-quaternary') + expect(separator).toBeInTheDocument() + expect(separator?.textContent).toBe('·') + }) + + it('should render separator dot before local badge', () => { + const { container } = render(<PluginSourceBadge source={PluginSource.local} />) + + const separator = container.querySelector('.text-text-quaternary') + expect(separator).toBeInTheDocument() + }) + + it('should render separator dot before debugging badge', () => { + const { container } = render(<PluginSourceBadge source={PluginSource.debugging} />) + + const separator = container.querySelector('.text-text-quaternary') + expect(separator).toBeInTheDocument() + }) + }) + + describe('Tooltip Content', () => { + it('should show marketplace tooltip', () => { + render(<PluginSourceBadge source={PluginSource.marketplace} />) + + expect(screen.getByTestId('tooltip')).toHaveAttribute( + 'data-content', + 'detailPanel.categoryTip.marketplace', + ) + }) + + it('should show github tooltip', () => { + render(<PluginSourceBadge source={PluginSource.github} />) + + expect(screen.getByTestId('tooltip')).toHaveAttribute( + 'data-content', + 'detailPanel.categoryTip.github', + ) + }) + + it('should show local tooltip', () => { + render(<PluginSourceBadge source={PluginSource.local} />) + + expect(screen.getByTestId('tooltip')).toHaveAttribute( + 'data-content', + 'detailPanel.categoryTip.local', + ) + }) + + it('should show debugging tooltip', () => { + render(<PluginSourceBadge source={PluginSource.debugging} />) + + expect(screen.getByTestId('tooltip')).toHaveAttribute( + 'data-content', + 'detailPanel.categoryTip.debugging', + ) + }) + }) + + describe('Icon Element Structure', () => { + it('should render icon inside tooltip for marketplace', () => { + render(<PluginSourceBadge source={PluginSource.marketplace} />) + + const tooltip = screen.getByTestId('tooltip') + const iconWrapper = tooltip.querySelector('div') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should render icon inside tooltip for github', () => { + render(<PluginSourceBadge source={PluginSource.github} />) + + const tooltip = screen.getByTestId('tooltip') + const iconWrapper = tooltip.querySelector('div') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should render icon inside tooltip for local', () => { + render(<PluginSourceBadge source={PluginSource.local} />) + + const tooltip = screen.getByTestId('tooltip') + const iconWrapper = tooltip.querySelector('div') + expect(iconWrapper).toBeInTheDocument() + }) + + it('should render icon inside tooltip for debugging', () => { + render(<PluginSourceBadge source={PluginSource.debugging} />) + + const tooltip = screen.getByTestId('tooltip') + const iconWrapper = tooltip.querySelector('div') + expect(iconWrapper).toBeInTheDocument() + }) + }) + + describe('Lookup Table Coverage', () => { + it('should handle all PluginSource enum values', () => { + const allSources = Object.values(PluginSource) + + allSources.forEach((source) => { + const { container } = render(<PluginSourceBadge source={source} />) + // Should render either tooltip or nothing + expect(container).toBeTruthy() + }) + }) + }) + + describe('Invalid Source Handling', () => { + it('should return null for unknown source type', () => { + // Use type assertion to test invalid source value + const invalidSource = 'unknown_source' as PluginSource + const { container } = render(<PluginSourceBadge source={invalidSource} />) + + // Should render nothing (empty container) + expect(container.firstChild).toBeNull() + }) + + it('should not render separator for invalid source', () => { + const invalidSource = 'invalid' as PluginSource + const { container } = render(<PluginSourceBadge source={invalidSource} />) + + const separator = container.querySelector('.text-text-quaternary') + expect(separator).not.toBeInTheDocument() + }) + + it('should not render tooltip for invalid source', () => { + const invalidSource = '' as PluginSource + render(<PluginSourceBadge source={invalidSource} />) + + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx new file mode 100644 index 0000000000..e886cec4da --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { FC, ReactNode } from 'react' +import { + RiBugLine, + RiHardDrive3Line, +} from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { Github } from '@/app/components/base/icons/src/public/common' +import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' +import Tooltip from '@/app/components/base/tooltip' +import { PluginSource } from '../../../types' + +type SourceConfig = { + icon: ReactNode + tipKey: string +} + +type PluginSourceBadgeProps = { + source: PluginSource +} + +const SOURCE_CONFIG_MAP: Record<PluginSource, SourceConfig | null> = { + [PluginSource.marketplace]: { + icon: <BoxSparkleFill className="h-3.5 w-3.5 text-text-tertiary hover:text-text-accent" />, + tipKey: 'detailPanel.categoryTip.marketplace', + }, + [PluginSource.github]: { + icon: <Github className="h-3.5 w-3.5 text-text-secondary hover:text-text-primary" />, + tipKey: 'detailPanel.categoryTip.github', + }, + [PluginSource.local]: { + icon: <RiHardDrive3Line className="h-3.5 w-3.5 text-text-tertiary" />, + tipKey: 'detailPanel.categoryTip.local', + }, + [PluginSource.debugging]: { + icon: <RiBugLine className="h-3.5 w-3.5 text-text-tertiary hover:text-text-warning" />, + tipKey: 'detailPanel.categoryTip.debugging', + }, +} + +const PluginSourceBadge: FC<PluginSourceBadgeProps> = ({ source }) => { + const { t } = useTranslation() + + const config = SOURCE_CONFIG_MAP[source] + if (!config) + return null + + return ( + <> + <div className="system-xs-regular ml-1 mr-0.5 text-text-quaternary">·</div> + <Tooltip popupContent={t(config.tipKey as never, { ns: 'plugin' })}> + <div>{config.icon}</div> + </Tooltip> + </> + ) +} + +export default PluginSourceBadge diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/index.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/index.ts new file mode 100644 index 0000000000..47b4d9b9a5 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/index.ts @@ -0,0 +1,3 @@ +export { useDetailHeaderState } from './use-detail-header-state' +export type { ModalStates, UseDetailHeaderStateReturn, VersionPickerState, VersionTarget } from './use-detail-header-state' +export { usePluginOperations } from './use-plugin-operations' diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts new file mode 100644 index 0000000000..2e14fed60a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.spec.ts @@ -0,0 +1,409 @@ +import type { PluginDetail } from '../../../types' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginSource } from '../../../types' +import { useDetailHeaderState } from './use-detail-header-state' + +let mockEnableMarketplace = true +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => + selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }), +})) + +let mockAutoUpgradeInfo: { + strategy_setting: string + upgrade_mode: string + include_plugins: string[] + exclude_plugins: string[] + upgrade_time_of_day: number +} | null = null + +vi.mock('../../../plugin-page/use-reference-setting', () => ({ + default: () => ({ + referenceSetting: mockAutoUpgradeInfo ? { auto_upgrade: mockAutoUpgradeInfo } : null, + }), +})) + +vi.mock('../../../reference-setting-modal/auto-update-setting/types', () => ({ + AUTO_UPDATE_MODE: { + update_all: 'update_all', + partial: 'partial', + exclude: 'exclude', + }, +})) + +const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + author: 'test-author', + name: 'test-plugin-name', + category: 'tool', + label: { en_US: 'Test Plugin Label' }, + description: { en_US: 'Test description' }, + icon: 'icon.png', + verified: true, + } as unknown as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-uid', + source: PluginSource.marketplace, + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +describe('useDetailHeaderState', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAutoUpgradeInfo = null + mockEnableMarketplace = true + }) + + describe('Source Type Detection', () => { + it('should detect marketplace source', () => { + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isFromMarketplace).toBe(true) + expect(result.current.isFromGitHub).toBe(false) + }) + + it('should detect GitHub source', () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isFromGitHub).toBe(true) + expect(result.current.isFromMarketplace).toBe(false) + }) + + it('should detect local source', () => { + const detail = createPluginDetail({ source: PluginSource.local }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isFromGitHub).toBe(false) + expect(result.current.isFromMarketplace).toBe(false) + }) + }) + + describe('Version State', () => { + it('should detect new version available for marketplace plugin', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + source: PluginSource.marketplace, + }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.hasNewVersion).toBe(true) + }) + + it('should not detect new version when versions match', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '1.0.0', + source: PluginSource.marketplace, + }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.hasNewVersion).toBe(false) + }) + + it('should not detect new version for non-marketplace source', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.hasNewVersion).toBe(false) + }) + + it('should not detect new version when latest_version is empty', () => { + const detail = createPluginDetail({ + version: '1.0.0', + latest_version: '', + source: PluginSource.marketplace, + }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.hasNewVersion).toBe(false) + }) + }) + + describe('Version Picker State', () => { + it('should initialize version picker as hidden', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.versionPicker.isShow).toBe(false) + }) + + it('should toggle version picker visibility', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + act(() => { + result.current.versionPicker.setIsShow(true) + }) + expect(result.current.versionPicker.isShow).toBe(true) + + act(() => { + result.current.versionPicker.setIsShow(false) + }) + expect(result.current.versionPicker.isShow).toBe(false) + }) + + it('should update target version', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + act(() => { + result.current.versionPicker.setTargetVersion({ + version: '2.0.0', + unique_identifier: 'new-uid', + }) + }) + + expect(result.current.versionPicker.targetVersion.version).toBe('2.0.0') + expect(result.current.versionPicker.targetVersion.unique_identifier).toBe('new-uid') + }) + + it('should set isDowngrade when provided in target version', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + act(() => { + result.current.versionPicker.setTargetVersion({ + version: '0.5.0', + unique_identifier: 'old-uid', + isDowngrade: true, + }) + }) + + expect(result.current.versionPicker.isDowngrade).toBe(true) + }) + }) + + describe('Modal States', () => { + it('should initialize all modals as hidden', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.modalStates.isShowUpdateModal).toBe(false) + expect(result.current.modalStates.isShowPluginInfo).toBe(false) + expect(result.current.modalStates.isShowDeleteConfirm).toBe(false) + expect(result.current.modalStates.deleting).toBe(false) + }) + + it('should toggle update modal', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + act(() => { + result.current.modalStates.showUpdateModal() + }) + expect(result.current.modalStates.isShowUpdateModal).toBe(true) + + act(() => { + result.current.modalStates.hideUpdateModal() + }) + expect(result.current.modalStates.isShowUpdateModal).toBe(false) + }) + + it('should toggle plugin info modal', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + act(() => { + result.current.modalStates.showPluginInfo() + }) + expect(result.current.modalStates.isShowPluginInfo).toBe(true) + + act(() => { + result.current.modalStates.hidePluginInfo() + }) + expect(result.current.modalStates.isShowPluginInfo).toBe(false) + }) + + it('should toggle delete confirm modal', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + act(() => { + result.current.modalStates.showDeleteConfirm() + }) + expect(result.current.modalStates.isShowDeleteConfirm).toBe(true) + + act(() => { + result.current.modalStates.hideDeleteConfirm() + }) + expect(result.current.modalStates.isShowDeleteConfirm).toBe(false) + }) + + it('should toggle deleting state', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => useDetailHeaderState(detail)) + + act(() => { + result.current.modalStates.showDeleting() + }) + expect(result.current.modalStates.deleting).toBe(true) + + act(() => { + result.current.modalStates.hideDeleting() + }) + expect(result.current.modalStates.deleting).toBe(false) + }) + }) + + describe('Auto Upgrade Detection', () => { + it('should disable auto upgrade when marketplace is disabled', () => { + mockEnableMarketplace = false + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(false) + }) + + it('should disable auto upgrade when strategy is disabled', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'disabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(false) + }) + + it('should enable auto upgrade for update_all mode', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(true) + }) + + it('should enable auto upgrade for partial mode when plugin is included', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'partial', + include_plugins: ['test-plugin'], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(true) + }) + + it('should disable auto upgrade for partial mode when plugin is not included', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'partial', + include_plugins: ['other-plugin'], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(false) + }) + + it('should enable auto upgrade for exclude mode when plugin is not excluded', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'exclude', + include_plugins: [], + exclude_plugins: ['other-plugin'], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(true) + }) + + it('should disable auto upgrade for exclude mode when plugin is excluded', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'exclude', + include_plugins: [], + exclude_plugins: ['test-plugin'], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(false) + }) + + it('should disable auto upgrade for non-marketplace source', () => { + mockAutoUpgradeInfo = { + strategy_setting: 'enabled', + upgrade_mode: 'update_all', + include_plugins: [], + exclude_plugins: [], + upgrade_time_of_day: 36000, + } + + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(false) + }) + + it('should disable auto upgrade when no auto upgrade info', () => { + mockAutoUpgradeInfo = null + + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => useDetailHeaderState(detail)) + + expect(result.current.isAutoUpgradeEnabled).toBe(false) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts new file mode 100644 index 0000000000..763ed9c992 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts @@ -0,0 +1,132 @@ +'use client' + +import type { PluginDetail } from '../../../types' +import { useBoolean } from 'ahooks' +import { useCallback, useMemo, useState } from 'react' +import { useGlobalPublicStore } from '@/context/global-public-context' +import useReferenceSetting from '../../../plugin-page/use-reference-setting' +import { AUTO_UPDATE_MODE } from '../../../reference-setting-modal/auto-update-setting/types' +import { PluginSource } from '../../../types' + +export type VersionTarget = { + version: string | undefined + unique_identifier: string | undefined + isDowngrade?: boolean +} + +export type ModalStates = { + isShowUpdateModal: boolean + showUpdateModal: () => void + hideUpdateModal: () => void + isShowPluginInfo: boolean + showPluginInfo: () => void + hidePluginInfo: () => void + isShowDeleteConfirm: boolean + showDeleteConfirm: () => void + hideDeleteConfirm: () => void + deleting: boolean + showDeleting: () => void + hideDeleting: () => void +} + +export type VersionPickerState = { + isShow: boolean + setIsShow: (show: boolean) => void + targetVersion: VersionTarget + setTargetVersion: (version: VersionTarget) => void + isDowngrade: boolean + setIsDowngrade: (downgrade: boolean) => void +} + +export type UseDetailHeaderStateReturn = { + modalStates: ModalStates + versionPicker: VersionPickerState + hasNewVersion: boolean + isAutoUpgradeEnabled: boolean + isFromGitHub: boolean + isFromMarketplace: boolean +} + +export const useDetailHeaderState = (detail: PluginDetail): UseDetailHeaderStateReturn => { + const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) + const { referenceSetting } = useReferenceSetting() + + const { + source, + version, + latest_version, + latest_unique_identifier, + plugin_id, + } = detail + + const isFromGitHub = source === PluginSource.github + const isFromMarketplace = source === PluginSource.marketplace + const [isShow, setIsShow] = useState(false) + const [targetVersion, setTargetVersion] = useState<VersionTarget>({ + version: latest_version, + unique_identifier: latest_unique_identifier, + }) + const [isDowngrade, setIsDowngrade] = useState(false) + + const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = useBoolean(false) + const [isShowPluginInfo, { setTrue: showPluginInfo, setFalse: hidePluginInfo }] = useBoolean(false) + const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, setFalse: hideDeleteConfirm }] = useBoolean(false) + const [deleting, { setTrue: showDeleting, setFalse: hideDeleting }] = useBoolean(false) + + const hasNewVersion = useMemo(() => { + if (isFromMarketplace) + return !!latest_version && latest_version !== version + return false + }, [isFromMarketplace, latest_version, version]) + + const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {} + + const isAutoUpgradeEnabled = useMemo(() => { + if (!enable_marketplace || !autoUpgradeInfo || !isFromMarketplace) + return false + if (autoUpgradeInfo.strategy_setting === 'disabled') + return false + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all) + return true + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id)) + return true + if (autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id)) + return true + return false + }, [autoUpgradeInfo, plugin_id, isFromMarketplace, enable_marketplace]) + + const handleSetTargetVersion = useCallback((version: VersionTarget) => { + setTargetVersion(version) + if (version.isDowngrade !== undefined) + setIsDowngrade(version.isDowngrade) + }, []) + + return { + modalStates: { + isShowUpdateModal, + showUpdateModal, + hideUpdateModal, + isShowPluginInfo, + showPluginInfo, + hidePluginInfo, + isShowDeleteConfirm, + showDeleteConfirm, + hideDeleteConfirm, + deleting, + showDeleting, + hideDeleting, + }, + versionPicker: { + isShow, + setIsShow, + targetVersion, + setTargetVersion: handleSetTargetVersion, + isDowngrade, + setIsDowngrade, + }, + hasNewVersion, + isAutoUpgradeEnabled, + isFromGitHub, + isFromMarketplace, + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts new file mode 100644 index 0000000000..683c4080ea --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.spec.ts @@ -0,0 +1,549 @@ +import type { PluginDetail } from '../../../types' +import type { ModalStates, VersionTarget } from './use-detail-header-state' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as amplitude from '@/app/components/base/amplitude' +import Toast from '@/app/components/base/toast' +import { PluginSource } from '../../../types' +import { usePluginOperations } from './use-plugin-operations' + +type VersionPickerMock = { + setTargetVersion: (version: VersionTarget) => void + setIsDowngrade: (downgrade: boolean) => void +} + +const { + mockSetShowUpdatePluginModal, + mockRefreshModelProviders, + mockInvalidateAllToolProviders, + mockUninstallPlugin, + mockFetchReleases, + mockCheckForUpdates, +} = vi.hoisted(() => { + return { + mockSetShowUpdatePluginModal: vi.fn(), + mockRefreshModelProviders: vi.fn(), + mockInvalidateAllToolProviders: vi.fn(), + mockUninstallPlugin: vi.fn(() => Promise.resolve({ success: true })), + mockFetchReleases: vi.fn(() => Promise.resolve([{ tag_name: 'v2.0.0' }])), + mockCheckForUpdates: vi.fn(() => ({ needUpdate: true, toastProps: { type: 'success', message: 'Update available' } })), + } +}) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowUpdatePluginModal: mockSetShowUpdatePluginModal, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + refreshModelProviders: mockRefreshModelProviders, + }), +})) + +vi.mock('@/service/plugins', () => ({ + uninstallPlugin: mockUninstallPlugin, +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders, +})) + +vi.mock('../../../install-plugin/hooks', () => ({ + useGitHubReleases: () => ({ + checkForUpdates: mockCheckForUpdates, + fetchReleases: mockFetchReleases, + }), +})) + +const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({ + id: 'test-id', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'Test Plugin', + plugin_id: 'test-plugin', + plugin_unique_identifier: 'test-uid', + declaration: { + author: 'test-author', + name: 'test-plugin-name', + category: 'tool', + label: { en_US: 'Test Plugin Label' }, + description: { en_US: 'Test description' }, + icon: 'icon.png', + verified: true, + } as unknown as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '2.0.0', + latest_unique_identifier: 'new-uid', + source: PluginSource.marketplace, + meta: undefined, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +const createModalStatesMock = (): ModalStates => ({ + isShowUpdateModal: false, + showUpdateModal: vi.fn(), + hideUpdateModal: vi.fn(), + isShowPluginInfo: false, + showPluginInfo: vi.fn(), + hidePluginInfo: vi.fn(), + isShowDeleteConfirm: false, + showDeleteConfirm: vi.fn(), + hideDeleteConfirm: vi.fn(), + deleting: false, + showDeleting: vi.fn(), + hideDeleting: vi.fn(), +}) + +const createVersionPickerMock = (): VersionPickerMock => ({ + setTargetVersion: vi.fn<(version: VersionTarget) => void>(), + setIsDowngrade: vi.fn<(downgrade: boolean) => void>(), +}) + +describe('usePluginOperations', () => { + let modalStates: ModalStates + let versionPicker: VersionPickerMock + let mockOnUpdate: (isDelete?: boolean) => void + + beforeEach(() => { + vi.clearAllMocks() + modalStates = createModalStatesMock() + versionPicker = createVersionPickerMock() + mockOnUpdate = vi.fn() + vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {}) + }) + + describe('Marketplace Update Flow', () => { + it('should show update modal for marketplace plugin', async () => { + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleUpdate() + }) + + expect(modalStates.showUpdateModal).toHaveBeenCalled() + }) + + it('should set isDowngrade when downgrading', async () => { + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleUpdate(true) + }) + + expect(versionPicker.setIsDowngrade).toHaveBeenCalledWith(true) + expect(modalStates.showUpdateModal).toHaveBeenCalled() + }) + + it('should call onUpdate and hide modal on successful marketplace update', () => { + const detail = createPluginDetail({ source: PluginSource.marketplace }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + act(() => { + result.current.handleUpdatedFromMarketplace() + }) + + expect(mockOnUpdate).toHaveBeenCalled() + expect(modalStates.hideUpdateModal).toHaveBeenCalled() + }) + }) + + describe('GitHub Update Flow', () => { + it('should fetch releases from GitHub', async () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: false, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleUpdate() + }) + + expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo') + }) + + it('should check for updates after fetching releases', async () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: false, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleUpdate() + }) + + expect(mockCheckForUpdates).toHaveBeenCalled() + expect(Toast.notify).toHaveBeenCalled() + }) + + it('should show update plugin modal when update is needed', async () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: false, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleUpdate() + }) + + expect(mockSetShowUpdatePluginModal).toHaveBeenCalled() + }) + + it('should not show modal when no releases found', async () => { + mockFetchReleases.mockResolvedValueOnce([]) + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: false, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleUpdate() + }) + + expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled() + }) + + it('should not show modal when no update needed', async () => { + mockCheckForUpdates.mockReturnValueOnce({ + needUpdate: false, + toastProps: { type: 'info', message: 'Already up to date' }, + }) + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: 'v1.0.0', package: 'pkg' }, + }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: false, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleUpdate() + }) + + expect(mockSetShowUpdatePluginModal).not.toHaveBeenCalled() + }) + + it('should use author and name as fallback for repo parsing', async () => { + const detail = createPluginDetail({ + source: PluginSource.github, + meta: { repo: '/', version: 'v1.0.0', package: 'pkg' }, + declaration: { + author: 'fallback-author', + name: 'fallback-name', + category: 'tool', + label: { en_US: 'Test' }, + description: { en_US: 'Test' }, + icon: 'icon.png', + verified: true, + } as unknown as PluginDetail['declaration'], + }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: false, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleUpdate() + }) + + expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-name') + }) + }) + + describe('Delete Flow', () => { + it('should call uninstallPlugin with correct id', async () => { + const detail = createPluginDetail({ id: 'plugin-to-delete' }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleDelete() + }) + + expect(mockUninstallPlugin).toHaveBeenCalledWith('plugin-to-delete') + }) + + it('should show and hide deleting state during delete', async () => { + const detail = createPluginDetail() + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleDelete() + }) + + expect(modalStates.showDeleting).toHaveBeenCalled() + expect(modalStates.hideDeleting).toHaveBeenCalled() + }) + + it('should call onUpdate with true after successful delete', async () => { + const detail = createPluginDetail() + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleDelete() + }) + + expect(mockOnUpdate).toHaveBeenCalledWith(true) + }) + + it('should hide delete confirm after successful delete', async () => { + const detail = createPluginDetail() + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleDelete() + }) + + expect(modalStates.hideDeleteConfirm).toHaveBeenCalled() + }) + + it('should refresh model providers when deleting model plugin', async () => { + const detail = createPluginDetail({ + declaration: { + author: 'test-author', + name: 'test-plugin-name', + category: 'model', + label: { en_US: 'Test' }, + description: { en_US: 'Test' }, + icon: 'icon.png', + verified: true, + } as unknown as PluginDetail['declaration'], + }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleDelete() + }) + + expect(mockRefreshModelProviders).toHaveBeenCalled() + }) + + it('should invalidate tool providers when deleting tool plugin', async () => { + const detail = createPluginDetail({ + declaration: { + author: 'test-author', + name: 'test-plugin-name', + category: 'tool', + label: { en_US: 'Test' }, + description: { en_US: 'Test' }, + icon: 'icon.png', + verified: true, + } as unknown as PluginDetail['declaration'], + }) + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleDelete() + }) + + expect(mockInvalidateAllToolProviders).toHaveBeenCalled() + }) + + it('should track plugin uninstalled event', async () => { + const detail = createPluginDetail() + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleDelete() + }) + + expect(amplitude.trackEvent).toHaveBeenCalledWith('plugin_uninstalled', expect.objectContaining({ + plugin_id: 'test-plugin', + plugin_name: 'test-plugin-name', + })) + }) + + it('should not call onUpdate when delete fails', async () => { + mockUninstallPlugin.mockResolvedValueOnce({ success: false }) + const detail = createPluginDetail() + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + onUpdate: mockOnUpdate, + }), + ) + + await act(async () => { + await result.current.handleDelete() + }) + + expect(mockOnUpdate).not.toHaveBeenCalled() + }) + }) + + describe('Optional onUpdate Callback', () => { + it('should not throw when onUpdate is not provided for marketplace update', () => { + const detail = createPluginDetail() + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + }), + ) + + expect(() => { + result.current.handleUpdatedFromMarketplace() + }).not.toThrow() + }) + + it('should not throw when onUpdate is not provided for delete', async () => { + const detail = createPluginDetail() + const { result } = renderHook(() => + usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace: true, + }), + ) + + await expect( + act(async () => { + await result.current.handleDelete() + }), + ).resolves.not.toThrow() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts new file mode 100644 index 0000000000..f3f0296a88 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-plugin-operations.ts @@ -0,0 +1,143 @@ +'use client' + +import type { PluginDetail } from '../../../types' +import type { ModalStates, VersionTarget } from './use-detail-header-state' +import { useCallback } from 'react' +import { trackEvent } from '@/app/components/base/amplitude' +import Toast from '@/app/components/base/toast' +import { useModalContext } from '@/context/modal-context' +import { useProviderContext } from '@/context/provider-context' +import { uninstallPlugin } from '@/service/plugins' +import { useInvalidateAllToolProviders } from '@/service/use-tools' +import { useGitHubReleases } from '../../../install-plugin/hooks' +import { PluginCategoryEnum, PluginSource } from '../../../types' + +type UsePluginOperationsParams = { + detail: PluginDetail + modalStates: ModalStates + versionPicker: { + setTargetVersion: (version: VersionTarget) => void + setIsDowngrade: (downgrade: boolean) => void + } + isFromMarketplace: boolean + onUpdate?: (isDelete?: boolean) => void +} + +type UsePluginOperationsReturn = { + handleUpdate: (isDowngrade?: boolean) => Promise<void> + handleUpdatedFromMarketplace: () => void + handleDelete: () => Promise<void> +} + +export const usePluginOperations = ({ + detail, + modalStates, + versionPicker, + isFromMarketplace, + onUpdate, +}: UsePluginOperationsParams): UsePluginOperationsReturn => { + const { checkForUpdates, fetchReleases } = useGitHubReleases() + const { setShowUpdatePluginModal } = useModalContext() + const { refreshModelProviders } = useProviderContext() + const invalidateAllToolProviders = useInvalidateAllToolProviders() + + const { id, meta, plugin_id } = detail + const { author, category, name } = detail.declaration || detail + + const handleUpdate = useCallback(async (isDowngrade?: boolean) => { + if (isFromMarketplace) { + versionPicker.setIsDowngrade(!!isDowngrade) + modalStates.showUpdateModal() + return + } + + if (!meta?.repo || !meta?.version || !meta?.package) { + Toast.notify({ + type: 'error', + message: 'Missing plugin metadata for GitHub update', + }) + return + } + + const owner = meta.repo.split('/')[0] || author + const repo = meta.repo.split('/')[1] || name + const fetchedReleases = await fetchReleases(owner, repo) + if (fetchedReleases.length === 0) + return + + const { needUpdate, toastProps } = checkForUpdates(fetchedReleases, meta.version) + Toast.notify(toastProps) + + if (needUpdate) { + setShowUpdatePluginModal({ + onSaveCallback: () => { + onUpdate?.() + }, + payload: { + type: PluginSource.github, + category, + github: { + originalPackageInfo: { + id: detail.plugin_unique_identifier, + repo: meta.repo, + version: meta.version, + package: meta.package, + releases: fetchedReleases, + }, + }, + }, + }) + } + }, [ + isFromMarketplace, + meta, + author, + name, + fetchReleases, + checkForUpdates, + setShowUpdatePluginModal, + detail, + onUpdate, + modalStates, + versionPicker, + ]) + + const handleUpdatedFromMarketplace = useCallback(() => { + onUpdate?.() + modalStates.hideUpdateModal() + }, [onUpdate, modalStates]) + + const handleDelete = useCallback(async () => { + modalStates.showDeleting() + const res = await uninstallPlugin(id) + modalStates.hideDeleting() + + if (res.success) { + modalStates.hideDeleteConfirm() + onUpdate?.(true) + + if (PluginCategoryEnum.model.includes(category)) + refreshModelProviders() + + if (PluginCategoryEnum.tool.includes(category)) + invalidateAllToolProviders() + + trackEvent('plugin_uninstalled', { plugin_id, plugin_name: name }) + } + }, [ + id, + category, + plugin_id, + name, + modalStates, + onUpdate, + refreshModelProviders, + invalidateAllToolProviders, + ]) + + return { + handleUpdate, + handleUpdatedFromMarketplace, + handleDelete, + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx new file mode 100644 index 0000000000..8f265c5717 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx @@ -0,0 +1,286 @@ +'use client' + +import type { PluginDetail } from '../../types' +import { + RiArrowLeftRightLine, + RiCloseLine, +} from '@remixicon/react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import ActionButton from '@/app/components/base/action-button' +import Badge from '@/app/components/base/badge' +import Button from '@/app/components/base/button' +import Tooltip from '@/app/components/base/tooltip' +import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth' +import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' +import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' +import { API_PREFIX } from '@/config' +import { useAppContext } from '@/context/app-context' +import { useGetLanguage, useLocale } from '@/context/i18n' +import useTheme from '@/hooks/use-theme' +import { useAllToolProviders } from '@/service/use-tools' +import { cn } from '@/utils/classnames' +import { getMarketplaceUrl } from '@/utils/var' +import { AutoUpdateLine } from '../../../base/icons/src/vender/system' +import Verified from '../../base/badges/verified' +import DeprecationNotice from '../../base/deprecation-notice' +import Icon from '../../card/base/card-icon' +import Description from '../../card/base/description' +import OrgInfo from '../../card/base/org-info' +import Title from '../../card/base/title' +import useReferenceSetting from '../../plugin-page/use-reference-setting' +import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../../reference-setting-modal/auto-update-setting/utils' +import { PluginCategoryEnum, PluginSource } from '../../types' +import { HeaderModals, PluginSourceBadge } from './components' +import { useDetailHeaderState, usePluginOperations } from './hooks' + +type Props = { + detail: PluginDetail + isReadmeView?: boolean + onHide?: () => void + onUpdate?: (isDelete?: boolean) => void +} + +const getIconSrc = (icon: string | undefined, iconDark: string | undefined, theme: string, tenantId: string): string => { + const iconFileName = theme === 'dark' && iconDark ? iconDark : icon + if (!iconFileName) + return '' + return iconFileName.startsWith('http') + ? iconFileName + : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenantId}&filename=${iconFileName}` +} + +const getDetailUrl = ( + source: PluginSource, + meta: PluginDetail['meta'], + author: string, + name: string, + locale: string, + theme: string, +): string => { + if (source === PluginSource.github) { + const repo = meta?.repo + if (!repo) + return '' + return `https://github.com/${repo}` + } + if (source === PluginSource.marketplace) + return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme }) + return '' +} + +const DetailHeader = ({ + detail, + isReadmeView = false, + onHide, + onUpdate, +}: Props) => { + const { t } = useTranslation() + const { userProfile: { timezone } } = useAppContext() + const { theme } = useTheme() + const locale = useGetLanguage() + const currentLocale = useLocale() + const { referenceSetting } = useReferenceSetting() + + const { + source, + tenant_id, + version, + latest_version, + latest_unique_identifier, + meta, + plugin_id, + status, + deprecated_reason, + alternative_plugin_id, + } = detail + + const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail + + const { + modalStates, + versionPicker, + hasNewVersion, + isAutoUpgradeEnabled, + isFromGitHub, + isFromMarketplace, + } = useDetailHeaderState(detail) + + const { + handleUpdate, + handleUpdatedFromMarketplace, + handleDelete, + } = usePluginOperations({ + detail, + modalStates, + versionPicker, + isFromMarketplace, + onUpdate, + }) + + const isTool = category === PluginCategoryEnum.tool + const providerBriefInfo = tool?.identity + const providerKey = `${plugin_id}/${providerBriefInfo?.name}` + const { data: collectionList = [] } = useAllToolProviders(isTool) + const provider = useMemo(() => { + return collectionList.find(collection => collection.name === providerKey) + }, [collectionList, providerKey]) + + const iconSrc = getIconSrc(icon, icon_dark, theme, tenant_id) + const detailUrl = getDetailUrl(source, meta, author, name, currentLocale, theme) + const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {} + + const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => { + versionPicker.setTargetVersion(state) + handleUpdate(state.isDowngrade) + } + + const handleTriggerLatestUpdate = () => { + if (isFromMarketplace) { + versionPicker.setTargetVersion({ + version: latest_version, + unique_identifier: latest_unique_identifier, + }) + } + handleUpdate() + } + + return ( + <div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}> + <div className="flex"> + {/* Plugin Icon */} + <div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}> + <Icon src={iconSrc} /> + </div> + + {/* Plugin Info */} + <div className="ml-3 w-0 grow"> + {/* Title Row */} + <div className="flex h-5 items-center"> + <Title title={label[locale]} /> + {verified && !isReadmeView && <Verified className="ml-0.5 h-4 w-4" text={t('marketplace.verifiedTip', { ns: 'plugin' })} />} + + {/* Version Picker */} + {!!version && ( + <PluginVersionPicker + disabled={!isFromMarketplace || isReadmeView} + isShow={versionPicker.isShow} + onShowChange={versionPicker.setIsShow} + pluginID={plugin_id} + currentVersion={version} + onSelect={handleVersionSelect} + trigger={( + <Badge + className={cn( + 'mx-1', + versionPicker.isShow && 'bg-state-base-hover', + (versionPicker.isShow || isFromMarketplace) && 'hover:bg-state-base-hover', + )} + uppercase={false} + text={( + <> + <div>{isFromGitHub ? (meta?.version ?? version ?? '') : version}</div> + {isFromMarketplace && !isReadmeView && <RiArrowLeftRightLine className="ml-1 h-3 w-3 text-text-tertiary" />} + </> + )} + hasRedCornerMark={hasNewVersion} + /> + )} + /> + )} + + {/* Auto Update Badge */} + {isAutoUpgradeEnabled && !isReadmeView && ( + <Tooltip popupContent={t('autoUpdate.nextUpdateTime', { ns: 'plugin', time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}> + <div> + <Badge className="mr-1 cursor-pointer px-1"> + <AutoUpdateLine className="size-3" /> + </Badge> + </div> + </Tooltip> + )} + + {/* Update Button */} + {(hasNewVersion || isFromGitHub) && ( + <Button + variant="secondary-accent" + size="small" + className="!h-5" + onClick={handleTriggerLatestUpdate} + > + {t('detailPanel.operation.update', { ns: 'plugin' })} + </Button> + )} + </div> + + {/* Org Info Row */} + <div className="mb-1 flex h-4 items-center justify-between"> + <div className="mt-0.5 flex items-center"> + <OrgInfo + packageNameClassName="w-auto" + orgName={author} + packageName={name?.includes('/') ? (name.split('/').pop() || '') : name} + /> + {!!source && <PluginSourceBadge source={source} />} + </div> + </div> + </div> + + {/* Action Buttons */} + {!isReadmeView && ( + <div className="flex gap-1"> + <OperationDropdown + source={source} + onInfo={modalStates.showPluginInfo} + onCheckVersion={handleUpdate} + onRemove={modalStates.showDeleteConfirm} + detailUrl={detailUrl} + /> + <ActionButton onClick={onHide}> + <RiCloseLine className="h-4 w-4" /> + </ActionButton> + </div> + )} + </div> + + {/* Deprecation Notice */} + {isFromMarketplace && ( + <DeprecationNotice + status={status} + deprecatedReason={deprecated_reason} + alternativePluginId={alternative_plugin_id} + alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })} + className="mt-3" + /> + )} + + {/* Description */} + {!isReadmeView && <Description className="mb-2 mt-3 h-auto" text={description[locale]} descriptionLineRows={2} />} + + {/* Plugin Auth for Tools */} + {category === PluginCategoryEnum.tool && !isReadmeView && ( + <PluginAuth + pluginPayload={{ + provider: provider?.name || '', + category: AuthCategory.tool, + providerType: provider?.type || '', + detail, + }} + /> + )} + + {/* Modals */} + <HeaderModals + detail={detail} + modalStates={modalStates} + targetVersion={versionPicker.targetVersion} + isDowngrade={versionPicker.isDowngrade} + isAutoUpgradeEnabled={isAutoUpgradeEnabled} + onUpdatedFromMarketplace={handleUpdatedFromMarketplace} + onDelete={handleDelete} + /> + </div> + ) +} + +export default DetailHeader diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx index 9155fa15be..b7e4f01f58 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx @@ -2,15 +2,10 @@ import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// Import after mocks import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' import { CommonCreateModal } from './common-modal' -// ============================================================================ -// Type Definitions -// ============================================================================ - type PluginDetail = { plugin_id: string provider: string @@ -33,10 +28,6 @@ type TriggerLogEntity = { level: 'info' | 'warn' | 'error' } -// ============================================================================ -// Mock Factory Functions -// ============================================================================ - function createMockPluginDetail(overrides: Partial<PluginDetail> = {}): PluginDetail { return { plugin_id: 'test-plugin-id', @@ -74,18 +65,12 @@ function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEnt return { logs } } -// ============================================================================ -// Mock Setup -// ============================================================================ - -// Mock plugin store const mockPluginDetail = createMockPluginDetail() const mockUsePluginStore = vi.fn(() => mockPluginDetail) vi.mock('../../store', () => ({ usePluginStore: () => mockUsePluginStore(), })) -// Mock subscription list hook const mockRefetch = vi.fn() vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ @@ -93,13 +78,11 @@ vi.mock('../use-subscription-list', () => ({ }), })) -// Mock service hooks const mockVerifyCredentials = vi.fn() const mockCreateBuilder = vi.fn() const mockBuildSubscription = vi.fn() const mockUpdateBuilder = vi.fn() -// Configurable pending states let mockIsVerifyingCredentials = false let mockIsBuilding = false const setMockPendingStates = (verifying: boolean, building: boolean) => { @@ -129,18 +112,15 @@ vi.mock('@/service/use-triggers', () => ({ }), })) -// Mock error parser const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null) vi.mock('@/utils/error-parser', () => ({ parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args), })) -// Mock URL validation vi.mock('@/utils/urlValidation', () => ({ isPrivateOrLocalAddress: vi.fn().mockReturnValue(false), })) -// Mock toast const mockToastNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ default: { @@ -148,7 +128,6 @@ vi.mock('@/app/components/base/toast', () => ({ }, })) -// Mock Modal component vi.mock('@/app/components/base/modal/modal', () => ({ default: ({ children, @@ -179,7 +158,6 @@ vi.mock('@/app/components/base/modal/modal', () => ({ ), })) -// Configurable form mock values type MockFormValuesConfig = { values: Record<string, unknown> isCheckValidated: boolean @@ -190,7 +168,6 @@ let mockFormValuesConfig: MockFormValuesConfig = { } let mockGetFormReturnsNull = false -// Separate validation configs for different forms let mockSubscriptionFormValidated = true let mockAutoParamsFormValidated = true let mockManualPropsFormValidated = true @@ -207,7 +184,6 @@ const setMockFormValidation = (subscription: boolean, autoParams: boolean, manua mockManualPropsFormValidated = manualProps } -// Mock BaseForm component with ref support vi.mock('@/app/components/base/form/components/base', async () => { const React = await import('react') @@ -219,7 +195,6 @@ vi.mock('@/app/components/base/form/components/base', async () => { type MockBaseFormProps = { formSchemas: Array<{ name: string }>, onChange?: () => void } function MockBaseFormInner({ formSchemas, onChange }: MockBaseFormProps, ref: React.ForwardedRef<MockFormRef>) { - // Determine which form this is based on schema const isSubscriptionForm = formSchemas.some((s: { name: string }) => s.name === 'subscription_name') const isAutoParamsForm = formSchemas.some((s: { name: string }) => ['repo_name', 'branch', 'repo', 'text_field', 'dynamic_field', 'bool_field', 'text_input_field', 'unknown_field', 'count'].includes(s.name), @@ -265,12 +240,10 @@ vi.mock('@/app/components/base/form/components/base', async () => { } }) -// Mock EncryptedBottom component vi.mock('@/app/components/base/encrypted-bottom', () => ({ EncryptedBottom: () => <div data-testid="encrypted-bottom">Encrypted</div>, })) -// Mock LogViewer component vi.mock('../log-viewer', () => ({ default: ({ logs }: { logs: TriggerLogEntity[] }) => ( <div data-testid="log-viewer"> @@ -281,7 +254,6 @@ vi.mock('../log-viewer', () => ({ ), })) -// Mock debounce vi.mock('es-toolkit/compat', () => ({ debounce: (fn: (...args: unknown[]) => unknown) => { const debouncedFn = (...args: unknown[]) => fn(...args) @@ -290,10 +262,6 @@ vi.mock('es-toolkit/compat', () => ({ }, })) -// ============================================================================ -// Test Suites -// ============================================================================ - describe('CommonCreateModal', () => { const defaultProps = { onClose: vi.fn(), @@ -441,7 +409,8 @@ describe('CommonCreateModal', () => { }) it('should call onConfirm handler when confirm button is clicked', () => { - render(<CommonCreateModal {...defaultProps} />) + // Provide builder so the guard passes and credentials check is reached + render(<CommonCreateModal {...defaultProps} builder={createMockSubscriptionBuilder()} />) fireEvent.click(screen.getByTestId('modal-confirm')) @@ -1243,13 +1212,22 @@ describe('CommonCreateModal', () => { render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />) + // Wait for createBuilder to complete and state to update await waitFor(() => { expect(mockCreateBuilder).toHaveBeenCalled() }) + // Allow React to process the state update from createBuilder + await act(async () => {}) + const input = screen.getByTestId('form-field-webhook_url') fireEvent.change(input, { target: { value: 'https://example.com/webhook' } }) + // Wait for updateBuilder to be called, then check the toast + await waitFor(() => { + expect(mockUpdateBuilder).toHaveBeenCalled() + }) + await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -1450,7 +1428,8 @@ describe('CommonCreateModal', () => { }) mockUsePluginStore.mockReturnValue(detailWithCredentials) - render(<CommonCreateModal {...defaultProps} />) + // Provide builder so the guard passes and credentials check is reached + render(<CommonCreateModal {...defaultProps} builder={createMockSubscriptionBuilder()} />) fireEvent.click(screen.getByTestId('modal-confirm')) 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 91a844fb86..15d3417c9b 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 @@ -1,32 +1,19 @@ 'use client' -import type { FormRefObject } from '@/app/components/base/form/types' import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' -import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' -import { RiLoader2Line } from '@remixicon/react' -import { debounce } from 'es-toolkit/compat' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -// import { CopyFeedbackNew } from '@/app/components/base/copy-feedback' import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' -import { BaseForm } from '@/app/components/base/form/components/base' -import { FormTypeEnum } from '@/app/components/base/form/types' import Modal from '@/app/components/base/modal/modal' -import Toast from '@/app/components/base/toast' import { SupportedCreationMethods } from '@/app/components/plugins/types' -import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' import { - useBuildTriggerSubscription, - useCreateTriggerSubscriptionBuilder, - useTriggerSubscriptionBuilderLogs, - useUpdateTriggerSubscriptionBuilder, - useVerifyAndUpdateTriggerSubscriptionBuilder, -} from '@/service/use-triggers' -import { parsePluginErrorMessage } from '@/utils/error-parser' -import { isPrivateOrLocalAddress } from '@/utils/urlValidation' -import { usePluginStore } from '../../store' -import LogViewer from '../log-viewer' -import { useSubscriptionList } from '../use-subscription-list' + ConfigurationStepContent, + MultiSteps, + VerifyStepContent, +} from './components/modal-steps' +import { + ApiKeyStep, + MODAL_TITLE_KEY_MAP, + useCommonModalState, +} from './hooks/use-common-modal-state' type Props = { onClose: () => void @@ -34,316 +21,33 @@ type Props = { builder?: TriggerSubscriptionBuilder } -const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = { - [SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey, - [SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2, - [SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized, -} - -const MODAL_TITLE_KEY_MAP: Record< - SupportedCreationMethods, - 'modal.apiKey.title' | 'modal.oauth.title' | 'modal.manual.title' -> = { - [SupportedCreationMethods.APIKEY]: 'modal.apiKey.title', - [SupportedCreationMethods.OAUTH]: 'modal.oauth.title', - [SupportedCreationMethods.MANUAL]: 'modal.manual.title', -} - -enum ApiKeyStep { - Verify = 'verify', - Configuration = 'configuration', -} - -const defaultFormValues = { values: {}, isCheckValidated: false } - -const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => { - if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) - return type as FormTypeEnum - - switch (type) { - case 'string': - case 'text': - return FormTypeEnum.textInput - case 'password': - case 'secret': - return FormTypeEnum.secretInput - case 'number': - case 'integer': - return FormTypeEnum.textNumber - case 'boolean': - return FormTypeEnum.boolean - default: - return FormTypeEnum.textInput - } -} - -const StatusStep = ({ isActive, text }: { isActive: boolean, text: string }) => { - return ( - <div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive - ? 'text-state-accent-solid' - : 'text-text-tertiary'}`} - > - {/* Active indicator dot */} - {isActive && ( - <div className="h-1 w-1 rounded-full bg-state-accent-solid"></div> - )} - {text} - </div> - ) -} - -const MultiSteps = ({ currentStep }: { currentStep: ApiKeyStep }) => { - const { t } = useTranslation() - return ( - <div className="mb-6 flex w-1/3 items-center gap-2"> - <StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('modal.steps.verify', { ns: 'pluginTrigger' })} /> - <div className="h-px w-3 shrink-0 bg-divider-deep"></div> - <StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('modal.steps.configuration', { ns: 'pluginTrigger' })} /> - </div> - ) -} - export const CommonCreateModal = ({ onClose, createType, builder }: Props) => { const { t } = useTranslation() - const detail = usePluginStore(state => state.detail) - const { refetch } = useSubscriptionList() - const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration) + const { + currentStep, + subscriptionBuilder, + isVerifyingCredentials, + isBuilding, + formRefs, + detail, + manualPropertiesSchema, + autoCommonParametersSchema, + apiKeyCredentialsSchema, + logData, + confirmButtonText, + handleConfirm, + handleManualPropertiesChange, + handleApiKeyCredentialsChange, + } = useCommonModalState({ + createType, + builder, + onClose, + }) - const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder) - const isInitializedRef = useRef(false) - - const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder() - const { mutateAsync: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder() - const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription() - const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder() - - const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] // manual - const manualPropertiesFormRef = React.useRef<FormRefObject>(null) - - const subscriptionFormRef = React.useRef<FormRefObject>(null) - - const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] // apikey and oauth - const autoCommonParametersFormRef = React.useRef<FormRefObject>(null) - - const apiKeyCredentialsSchema = useMemo(() => { - const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || [] - return rawSchema.map(schema => ({ - ...schema, - tooltip: schema.help, - })) - }, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema]) - const apiKeyCredentialsFormRef = React.useRef<FormRefObject>(null) - - const { data: logData } = useTriggerSubscriptionBuilderLogs( - detail?.provider || '', - subscriptionBuilder?.id || '', - { - enabled: createType === SupportedCreationMethods.MANUAL, - refetchInterval: 3000, - }, - ) - - useEffect(() => { - const initializeBuilder = async () => { - isInitializedRef.current = true - try { - const response = await createBuilder({ - provider: detail?.provider || '', - credential_type: CREDENTIAL_TYPE_MAP[createType], - }) - setSubscriptionBuilder(response.subscription_builder) - } - catch (error) { - console.error('createBuilder error:', error) - Toast.notify({ - type: 'error', - message: t('modal.errors.createFailed', { ns: 'pluginTrigger' }), - }) - } - } - if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider) - initializeBuilder() - }, [subscriptionBuilder, detail?.provider, createType, createBuilder, t]) - - useEffect(() => { - if (subscriptionBuilder?.endpoint && subscriptionFormRef.current && currentStep === ApiKeyStep.Configuration) { - const form = subscriptionFormRef.current.getForm() - if (form) - form.setFieldValue('callback_url', subscriptionBuilder.endpoint) - if (isPrivateOrLocalAddress(subscriptionBuilder.endpoint)) { - console.warn('callback_url is private or local address', subscriptionBuilder.endpoint) - subscriptionFormRef.current?.setFields([{ - name: 'callback_url', - warnings: [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })], - }]) - } - else { - subscriptionFormRef.current?.setFields([{ - name: 'callback_url', - warnings: [], - }]) - } - } - }, [subscriptionBuilder?.endpoint, currentStep, t]) - - const debouncedUpdate = useMemo( - () => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => { - updateBuilder( - { - provider, - subscriptionBuilderId: builderId, - properties, - }, - { - onError: async (error: unknown) => { - const errorMessage = await parsePluginErrorMessage(error) || t('modal.errors.updateFailed', { ns: 'pluginTrigger' }) - console.error('Failed to update subscription builder:', error) - Toast.notify({ - type: 'error', - message: errorMessage, - }) - }, - }, - ) - }, 500), - [updateBuilder, t], - ) - - const handleManualPropertiesChange = useCallback(() => { - if (!subscriptionBuilder || !detail?.provider) - return - - const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false }) || { values: {}, isCheckValidated: true } - - debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values) - }, [subscriptionBuilder, detail?.provider, debouncedUpdate]) - - useEffect(() => { - return () => { - debouncedUpdate.cancel() - } - }, [debouncedUpdate]) - - const handleVerify = () => { - const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || defaultFormValues - const credentials = apiKeyCredentialsFormValues.values - - if (!Object.keys(credentials).length) { - Toast.notify({ - type: 'error', - message: 'Please fill in all required credentials', - }) - return - } - - apiKeyCredentialsFormRef.current?.setFields([{ - name: Object.keys(credentials)[0], - errors: [], - }]) - - verifyCredentials( - { - provider: detail?.provider || '', - subscriptionBuilderId: subscriptionBuilder?.id || '', - credentials, - }, - { - onSuccess: () => { - Toast.notify({ - type: 'success', - message: t('modal.apiKey.verify.success', { ns: 'pluginTrigger' }), - }) - setCurrentStep(ApiKeyStep.Configuration) - }, - onError: async (error: unknown) => { - const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' }) - apiKeyCredentialsFormRef.current?.setFields([{ - name: Object.keys(credentials)[0], - errors: [errorMessage], - }]) - }, - }, - ) - } - - const handleCreate = () => { - if (!subscriptionBuilder) { - Toast.notify({ - type: 'error', - message: 'Subscription builder not found', - }) - return - } - - const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({}) - if (!subscriptionFormValues?.isCheckValidated) - return - - const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string - - const params: BuildTriggerSubscriptionPayload = { - provider: detail?.provider || '', - subscriptionBuilderId: subscriptionBuilder.id, - name: subscriptionNameValue, - } - - if (createType !== SupportedCreationMethods.MANUAL) { - if (autoCommonParametersSchema.length > 0) { - const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || defaultFormValues - if (!autoCommonParametersFormValues?.isCheckValidated) - return - params.parameters = autoCommonParametersFormValues.values - } - } - else if (manualPropertiesSchema.length > 0) { - const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || defaultFormValues - if (!manualFormValues?.isCheckValidated) - return - } - - buildSubscription( - params, - { - onSuccess: () => { - Toast.notify({ - type: 'success', - message: t('subscription.createSuccess', { ns: 'pluginTrigger' }), - }) - onClose() - refetch?.() - }, - onError: async (error: unknown) => { - const errorMessage = await parsePluginErrorMessage(error) || t('subscription.createFailed', { ns: 'pluginTrigger' }) - Toast.notify({ - type: 'error', - message: errorMessage, - }) - }, - }, - ) - } - - const handleConfirm = () => { - if (currentStep === ApiKeyStep.Verify) - handleVerify() - else - handleCreate() - } - - const handleApiKeyCredentialsChange = () => { - apiKeyCredentialsFormRef.current?.setFields([{ - name: apiKeyCredentialsSchema[0].name, - errors: [], - }]) - } - - const confirmButtonText = useMemo(() => { - if (currentStep === ApiKeyStep.Verify) - return isVerifyingCredentials ? t('modal.common.verifying', { ns: 'pluginTrigger' }) : t('modal.common.verify', { ns: 'pluginTrigger' }) - - return isBuilding ? t('modal.common.creating', { ns: 'pluginTrigger' }) : t('modal.common.create', { ns: 'pluginTrigger' }) - }, [currentStep, isVerifyingCredentials, isBuilding, t]) + const isApiKeyType = createType === SupportedCreationMethods.APIKEY + const isVerifyStep = currentStep === ApiKeyStep.Verify + const isConfigurationStep = currentStep === ApiKeyStep.Configuration return ( <Modal @@ -353,121 +57,36 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => { onCancel={onClose} onConfirm={handleConfirm} disabled={isVerifyingCredentials || isBuilding} - bottomSlot={currentStep === ApiKeyStep.Verify ? <EncryptedBottom /> : null} + bottomSlot={isVerifyStep ? <EncryptedBottom /> : null} size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'} containerClassName="min-h-[360px]" clickOutsideNotClose > - {createType === SupportedCreationMethods.APIKEY && <MultiSteps currentStep={currentStep} />} - {currentStep === ApiKeyStep.Verify && ( - <> - {apiKeyCredentialsSchema.length > 0 && ( - <div className="mb-4"> - <BaseForm - formSchemas={apiKeyCredentialsSchema} - ref={apiKeyCredentialsFormRef} - labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" - preventDefaultSubmit={true} - formClassName="space-y-4" - onChange={handleApiKeyCredentialsChange} - /> - </div> - )} - </> - )} - {currentStep === ApiKeyStep.Configuration && ( - <div className="max-h-[70vh]"> - <BaseForm - formSchemas={[ - { - name: 'subscription_name', - label: t('modal.form.subscriptionName.label', { ns: 'pluginTrigger' }), - placeholder: t('modal.form.subscriptionName.placeholder', { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: true, - }, - { - name: 'callback_url', - label: t('modal.form.callbackUrl.label', { ns: 'pluginTrigger' }), - placeholder: t('modal.form.callbackUrl.placeholder', { ns: 'pluginTrigger' }), - type: FormTypeEnum.textInput, - required: false, - default: subscriptionBuilder?.endpoint || '', - disabled: true, - tooltip: t('modal.form.callbackUrl.tooltip', { ns: 'pluginTrigger' }), - showCopy: true, - }, - ]} - ref={subscriptionFormRef} - labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" - formClassName="space-y-4 mb-4" - /> - {/* <div className='system-xs-regular mb-6 mt-[-1rem] text-text-tertiary'> - {t('pluginTrigger.modal.form.callbackUrl.description')} - </div> */} - {createType !== SupportedCreationMethods.MANUAL && autoCommonParametersSchema.length > 0 && ( - <BaseForm - formSchemas={autoCommonParametersSchema.map((schema) => { - const normalizedType = normalizeFormType(schema.type as FormTypeEnum | string) - return { - ...schema, - tooltip: schema.description, - type: normalizedType, - dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect - ? { - plugin_id: detail?.plugin_id || '', - provider: detail?.provider || '', - action: 'provider', - parameter: schema.name, - credential_id: subscriptionBuilder?.id || '', - } - : undefined, - fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, - labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined, - } - })} - ref={autoCommonParametersFormRef} - labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" - formClassName="space-y-4" - /> - )} - {createType === SupportedCreationMethods.MANUAL && ( - <> - {manualPropertiesSchema.length > 0 && ( - <div className="mb-6"> - <BaseForm - formSchemas={manualPropertiesSchema.map(schema => ({ - ...schema, - tooltip: schema.description, - }))} - ref={manualPropertiesFormRef} - labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" - formClassName="space-y-4" - onChange={handleManualPropertiesChange} - /> - </div> - )} - <div className="mb-6"> - <div className="mb-3 flex items-center gap-2"> - <div className="system-xs-medium-uppercase text-text-tertiary"> - {t('modal.manual.logs.title', { ns: 'pluginTrigger' })} - </div> - <div className="h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent" /> - </div> + {isApiKeyType && <MultiSteps currentStep={currentStep} />} - <div className="mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3"> - <div className="h-3.5 w-3.5"> - <RiLoader2Line className="h-full w-full animate-spin" /> - </div> - <div className="system-xs-regular text-text-tertiary"> - {t('modal.manual.logs.loading', { ns: 'pluginTrigger', pluginName: detail?.name || '' })} - </div> - </div> - <LogViewer logs={logData?.logs || []} /> - </div> - </> - )} - </div> + {isVerifyStep && ( + <VerifyStepContent + apiKeyCredentialsSchema={apiKeyCredentialsSchema} + apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef} + onChange={handleApiKeyCredentialsChange} + /> + )} + + {isConfigurationStep && ( + <ConfigurationStepContent + createType={createType} + subscriptionBuilder={subscriptionBuilder} + subscriptionFormRef={formRefs.subscriptionFormRef} + autoCommonParametersSchema={autoCommonParametersSchema} + autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef} + manualPropertiesSchema={manualPropertiesSchema} + manualPropertiesFormRef={formRefs.manualPropertiesFormRef} + onManualPropertiesChange={handleManualPropertiesChange} + logs={logData?.logs || []} + pluginId={detail?.plugin_id || ''} + pluginName={detail?.name || ''} + provider={detail?.provider || ''} + /> )} </Modal> ) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/modal-steps.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/modal-steps.tsx new file mode 100644 index 0000000000..795176d4f6 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/modal-steps.tsx @@ -0,0 +1,304 @@ +'use client' +import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' +import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { RiLoader2Line } from '@remixicon/react' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { BaseForm } from '@/app/components/base/form/components/base' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import LogViewer from '../../log-viewer' +import { ApiKeyStep } from '../hooks/use-common-modal-state' + +export type SchemaItem = Partial<FormSchema> & Record<string, unknown> & { + name: string +} + +type StatusStepProps = { + isActive: boolean + text: string +} + +export const StatusStep = ({ isActive, text }: StatusStepProps) => { + return ( + <div className={`system-2xs-semibold-uppercase flex items-center gap-1 ${isActive + ? 'text-state-accent-solid' + : 'text-text-tertiary'}`} + > + {isActive && ( + <div className="h-1 w-1 rounded-full bg-state-accent-solid"></div> + )} + {text} + </div> + ) +} + +type MultiStepsProps = { + currentStep: ApiKeyStep +} + +export const MultiSteps = ({ currentStep }: MultiStepsProps) => { + const { t } = useTranslation() + return ( + <div className="mb-6 flex w-1/3 items-center gap-2"> + <StatusStep isActive={currentStep === ApiKeyStep.Verify} text={t('modal.steps.verify', { ns: 'pluginTrigger' })} /> + <div className="h-px w-3 shrink-0 bg-divider-deep"></div> + <StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('modal.steps.configuration', { ns: 'pluginTrigger' })} /> + </div> + ) +} + +type VerifyStepContentProps = { + apiKeyCredentialsSchema: SchemaItem[] + apiKeyCredentialsFormRef: React.RefObject<FormRefObject | null> + onChange: () => void +} + +export const VerifyStepContent = ({ + apiKeyCredentialsSchema, + apiKeyCredentialsFormRef, + onChange, +}: VerifyStepContentProps) => { + if (!apiKeyCredentialsSchema.length) + return null + + return ( + <div className="mb-4"> + <BaseForm + formSchemas={apiKeyCredentialsSchema as FormSchema[]} + ref={apiKeyCredentialsFormRef} + labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" + preventDefaultSubmit={true} + formClassName="space-y-4" + onChange={onChange} + /> + </div> + ) +} + +type SubscriptionFormProps = { + subscriptionFormRef: React.RefObject<FormRefObject | null> + endpoint?: string +} + +export const SubscriptionForm = ({ + subscriptionFormRef, + endpoint, +}: SubscriptionFormProps) => { + const { t } = useTranslation() + + const formSchemas = React.useMemo(() => [ + { + name: 'subscription_name', + label: t('modal.form.subscriptionName.label', { ns: 'pluginTrigger' }), + placeholder: t('modal.form.subscriptionName.placeholder', { ns: 'pluginTrigger' }), + type: FormTypeEnum.textInput, + required: true, + }, + { + name: 'callback_url', + label: t('modal.form.callbackUrl.label', { ns: 'pluginTrigger' }), + placeholder: t('modal.form.callbackUrl.placeholder', { ns: 'pluginTrigger' }), + type: FormTypeEnum.textInput, + required: false, + default: endpoint || '', + disabled: true, + tooltip: t('modal.form.callbackUrl.tooltip', { ns: 'pluginTrigger' }), + showCopy: true, + }, + ], [endpoint, t]) + + return ( + <BaseForm + formSchemas={formSchemas} + ref={subscriptionFormRef} + labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" + formClassName="space-y-4 mb-4" + /> + ) +} + +const normalizeFormType = (type: FormTypeEnum | string): FormTypeEnum => { + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) + return type as FormTypeEnum + + const TYPE_MAP: Record<string, FormTypeEnum> = { + string: FormTypeEnum.textInput, + text: FormTypeEnum.textInput, + password: FormTypeEnum.secretInput, + secret: FormTypeEnum.secretInput, + number: FormTypeEnum.textNumber, + integer: FormTypeEnum.textNumber, + boolean: FormTypeEnum.boolean, + } + + return TYPE_MAP[type] || FormTypeEnum.textInput +} + +type AutoParametersFormProps = { + schemas: SchemaItem[] + formRef: React.RefObject<FormRefObject | null> + pluginId: string + provider: string + credentialId: string +} + +export const AutoParametersForm = ({ + schemas, + formRef, + pluginId, + provider, + credentialId, +}: AutoParametersFormProps) => { + const formSchemas = React.useMemo(() => + schemas.map((schema) => { + const normalizedType = normalizeFormType((schema.type || FormTypeEnum.textInput) as FormTypeEnum | string) + return { + ...schema, + tooltip: schema.description, + type: normalizedType, + dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect + ? { + plugin_id: pluginId, + provider, + action: 'provider', + parameter: schema.name, + credential_id: credentialId, + } + : undefined, + fieldClassName: normalizedType === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined, + labelClassName: normalizedType === FormTypeEnum.boolean ? 'mb-0' : undefined, + } + }) as FormSchema[], [schemas, pluginId, provider, credentialId]) + + if (!schemas.length) + return null + + return ( + <BaseForm + formSchemas={formSchemas} + ref={formRef} + labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" + formClassName="space-y-4" + /> + ) +} + +type ManualPropertiesSectionProps = { + schemas: SchemaItem[] + formRef: React.RefObject<FormRefObject | null> + onChange: () => void + logs: TriggerLogEntity[] + pluginName: string +} + +export const ManualPropertiesSection = ({ + schemas, + formRef, + onChange, + logs, + pluginName, +}: ManualPropertiesSectionProps) => { + const { t } = useTranslation() + + const formSchemas = React.useMemo(() => + schemas.map(schema => ({ + ...schema, + tooltip: schema.description, + })) as FormSchema[], [schemas]) + + return ( + <> + {schemas.length > 0 && ( + <div className="mb-6"> + <BaseForm + formSchemas={formSchemas} + ref={formRef} + labelClassName="system-sm-medium mb-2 flex items-center gap-1 text-text-primary" + formClassName="space-y-4" + onChange={onChange} + /> + </div> + )} + <div className="mb-6"> + <div className="mb-3 flex items-center gap-2"> + <div className="system-xs-medium-uppercase text-text-tertiary"> + {t('modal.manual.logs.title', { ns: 'pluginTrigger' })} + </div> + <div className="h-px flex-1 bg-gradient-to-r from-divider-regular to-transparent" /> + </div> + + <div className="mb-1 flex items-center justify-center gap-1 rounded-lg bg-background-section p-3"> + <div className="h-3.5 w-3.5"> + <RiLoader2Line className="h-full w-full animate-spin" /> + </div> + <div className="system-xs-regular text-text-tertiary"> + {t('modal.manual.logs.loading', { ns: 'pluginTrigger', pluginName })} + </div> + </div> + <LogViewer logs={logs} /> + </div> + </> + ) +} + +type ConfigurationStepContentProps = { + createType: SupportedCreationMethods + subscriptionBuilder?: TriggerSubscriptionBuilder + subscriptionFormRef: React.RefObject<FormRefObject | null> + autoCommonParametersSchema: SchemaItem[] + autoCommonParametersFormRef: React.RefObject<FormRefObject | null> + manualPropertiesSchema: SchemaItem[] + manualPropertiesFormRef: React.RefObject<FormRefObject | null> + onManualPropertiesChange: () => void + logs: TriggerLogEntity[] + pluginId: string + pluginName: string + provider: string +} + +export const ConfigurationStepContent = ({ + createType, + subscriptionBuilder, + subscriptionFormRef, + autoCommonParametersSchema, + autoCommonParametersFormRef, + manualPropertiesSchema, + manualPropertiesFormRef, + onManualPropertiesChange, + logs, + pluginId, + pluginName, + provider, +}: ConfigurationStepContentProps) => { + const isManualType = createType === SupportedCreationMethods.MANUAL + + return ( + <div className="max-h-[70vh]"> + <SubscriptionForm + subscriptionFormRef={subscriptionFormRef} + endpoint={subscriptionBuilder?.endpoint} + /> + + {!isManualType && autoCommonParametersSchema.length > 0 && ( + <AutoParametersForm + schemas={autoCommonParametersSchema} + formRef={autoCommonParametersFormRef} + pluginId={pluginId} + provider={provider} + credentialId={subscriptionBuilder?.id || ''} + /> + )} + + {isManualType && ( + <ManualPropertiesSection + schemas={manualPropertiesSchema} + formRef={manualPropertiesFormRef} + onChange={onManualPropertiesChange} + logs={logs} + pluginName={pluginName} + /> + )} + </div> + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts new file mode 100644 index 0000000000..b01312d3d1 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts @@ -0,0 +1,401 @@ +'use client' +import type { SimpleDetail } from '../../../store' +import type { SchemaItem } from '../components/modal-steps' +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' +import { debounce } from 'es-toolkit/compat' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Toast from '@/app/components/base/toast' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { + useBuildTriggerSubscription, + useCreateTriggerSubscriptionBuilder, + useTriggerSubscriptionBuilderLogs, + useUpdateTriggerSubscriptionBuilder, + useVerifyAndUpdateTriggerSubscriptionBuilder, +} from '@/service/use-triggers' +import { parsePluginErrorMessage } from '@/utils/error-parser' +import { isPrivateOrLocalAddress } from '@/utils/urlValidation' +import { usePluginStore } from '../../../store' +import { useSubscriptionList } from '../../use-subscription-list' + +// ============================================================================ +// Types +// ============================================================================ + +export enum ApiKeyStep { + Verify = 'verify', + Configuration = 'configuration', +} + +export const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = { + [SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey, + [SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2, + [SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized, +} + +export const MODAL_TITLE_KEY_MAP: Record< + SupportedCreationMethods, + 'modal.apiKey.title' | 'modal.oauth.title' | 'modal.manual.title' +> = { + [SupportedCreationMethods.APIKEY]: 'modal.apiKey.title', + [SupportedCreationMethods.OAUTH]: 'modal.oauth.title', + [SupportedCreationMethods.MANUAL]: 'modal.manual.title', +} + +type UseCommonModalStateParams = { + createType: SupportedCreationMethods + builder?: TriggerSubscriptionBuilder + onClose: () => void +} + +type FormRefs = { + manualPropertiesFormRef: React.RefObject<FormRefObject | null> + subscriptionFormRef: React.RefObject<FormRefObject | null> + autoCommonParametersFormRef: React.RefObject<FormRefObject | null> + apiKeyCredentialsFormRef: React.RefObject<FormRefObject | null> +} + +type UseCommonModalStateReturn = { + // State + currentStep: ApiKeyStep + subscriptionBuilder: TriggerSubscriptionBuilder | undefined + isVerifyingCredentials: boolean + isBuilding: boolean + + // Form refs + formRefs: FormRefs + + // Computed values + detail: SimpleDetail | undefined + manualPropertiesSchema: SchemaItem[] + autoCommonParametersSchema: SchemaItem[] + apiKeyCredentialsSchema: SchemaItem[] + logData: { logs: TriggerLogEntity[] } | undefined + confirmButtonText: string + + // Handlers + handleVerify: () => void + handleCreate: () => void + handleConfirm: () => void + handleManualPropertiesChange: () => void + handleApiKeyCredentialsChange: () => void +} + +const DEFAULT_FORM_VALUES = { values: {}, isCheckValidated: false } + +// ============================================================================ +// Hook Implementation +// ============================================================================ + +export const useCommonModalState = ({ + createType, + builder, + onClose, +}: UseCommonModalStateParams): UseCommonModalStateReturn => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + const { refetch } = useSubscriptionList() + + // State + const [currentStep, setCurrentStep] = useState<ApiKeyStep>( + createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration, + ) + const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>(builder) + const isInitializedRef = useRef(false) + + // Form refs + const manualPropertiesFormRef = useRef<FormRefObject>(null) + const subscriptionFormRef = useRef<FormRefObject>(null) + const autoCommonParametersFormRef = useRef<FormRefObject>(null) + const apiKeyCredentialsFormRef = useRef<FormRefObject>(null) + + // Mutations + const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyAndUpdateTriggerSubscriptionBuilder() + const { mutateAsync: createBuilder } = useCreateTriggerSubscriptionBuilder() + const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription() + const { mutate: updateBuilder } = useUpdateTriggerSubscriptionBuilder() + + // Schemas + const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] + const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] + + const apiKeyCredentialsSchema = useMemo(() => { + const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || [] + return rawSchema.map(schema => ({ + ...schema, + tooltip: schema.help, + })) + }, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema]) + + // Log data for manual mode + const { data: logData } = useTriggerSubscriptionBuilderLogs( + detail?.provider || '', + subscriptionBuilder?.id || '', + { + enabled: createType === SupportedCreationMethods.MANUAL, + refetchInterval: 3000, + }, + ) + + // Debounced update for manual properties + const debouncedUpdate = useMemo( + () => debounce((provider: string, builderId: string, properties: Record<string, unknown>) => { + updateBuilder( + { + provider, + subscriptionBuilderId: builderId, + properties, + }, + { + onError: async (error: unknown) => { + const errorMessage = await parsePluginErrorMessage(error) || t('modal.errors.updateFailed', { ns: 'pluginTrigger' }) + console.error('Failed to update subscription builder:', error) + Toast.notify({ + type: 'error', + message: errorMessage, + }) + }, + }, + ) + }, 500), + [updateBuilder, t], + ) + + // Initialize builder + useEffect(() => { + const initializeBuilder = async () => { + isInitializedRef.current = true + try { + const response = await createBuilder({ + provider: detail?.provider || '', + credential_type: CREDENTIAL_TYPE_MAP[createType], + }) + setSubscriptionBuilder(response.subscription_builder) + } + catch (error) { + console.error('createBuilder error:', error) + Toast.notify({ + type: 'error', + message: t('modal.errors.createFailed', { ns: 'pluginTrigger' }), + }) + } + } + if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider) + initializeBuilder() + }, [subscriptionBuilder, detail?.provider, createType, createBuilder, t]) + + // Cleanup debounced function + useEffect(() => { + return () => { + debouncedUpdate.cancel() + } + }, [debouncedUpdate]) + + // Update endpoint in form when endpoint changes + useEffect(() => { + if (!subscriptionBuilder?.endpoint || !subscriptionFormRef.current || currentStep !== ApiKeyStep.Configuration) + return + + const form = subscriptionFormRef.current.getForm() + if (form) + form.setFieldValue('callback_url', subscriptionBuilder.endpoint) + + const warnings = isPrivateOrLocalAddress(subscriptionBuilder.endpoint) + ? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })] + : [] + + subscriptionFormRef.current?.setFields([{ + name: 'callback_url', + warnings, + }]) + }, [subscriptionBuilder?.endpoint, currentStep, t]) + + // Handle manual properties change + const handleManualPropertiesChange = useCallback(() => { + if (!subscriptionBuilder || !detail?.provider) + return + + const formValues = manualPropertiesFormRef.current?.getFormValues({ needCheckValidatedValues: false }) + || { values: {}, isCheckValidated: true } + + debouncedUpdate(detail.provider, subscriptionBuilder.id, formValues.values) + }, [subscriptionBuilder, detail?.provider, debouncedUpdate]) + + // Handle API key credentials change + const handleApiKeyCredentialsChange = useCallback(() => { + if (!apiKeyCredentialsSchema.length) + return + apiKeyCredentialsFormRef.current?.setFields([{ + name: apiKeyCredentialsSchema[0].name, + errors: [], + }]) + }, [apiKeyCredentialsSchema]) + + // Handle verify + const handleVerify = useCallback(() => { + // Guard against uninitialized state + if (!detail?.provider || !subscriptionBuilder?.id) { + Toast.notify({ + type: 'error', + message: 'Subscription builder not initialized', + }) + return + } + + const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES + const credentials = apiKeyCredentialsFormValues.values + + if (!Object.keys(credentials).length) { + Toast.notify({ + type: 'error', + message: 'Please fill in all required credentials', + }) + return + } + + apiKeyCredentialsFormRef.current?.setFields([{ + name: Object.keys(credentials)[0], + errors: [], + }]) + + verifyCredentials( + { + provider: detail.provider, + subscriptionBuilderId: subscriptionBuilder.id, + credentials, + }, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('modal.apiKey.verify.success', { ns: 'pluginTrigger' }), + }) + setCurrentStep(ApiKeyStep.Configuration) + }, + onError: async (error: unknown) => { + const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' }) + apiKeyCredentialsFormRef.current?.setFields([{ + name: Object.keys(credentials)[0], + errors: [errorMessage], + }]) + }, + }, + ) + }, [detail?.provider, subscriptionBuilder?.id, verifyCredentials, t]) + + // Handle create + const handleCreate = useCallback(() => { + if (!subscriptionBuilder) { + Toast.notify({ + type: 'error', + message: 'Subscription builder not found', + }) + return + } + + const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({}) + if (!subscriptionFormValues?.isCheckValidated) + return + + const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string + + const params: BuildTriggerSubscriptionPayload = { + provider: detail?.provider || '', + subscriptionBuilderId: subscriptionBuilder.id, + name: subscriptionNameValue, + } + + if (createType !== SupportedCreationMethods.MANUAL) { + if (autoCommonParametersSchema.length > 0) { + const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES + if (!autoCommonParametersFormValues?.isCheckValidated) + return + params.parameters = autoCommonParametersFormValues.values + } + } + else if (manualPropertiesSchema.length > 0) { + const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES + if (!manualFormValues?.isCheckValidated) + return + } + + buildSubscription( + params, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('subscription.createSuccess', { ns: 'pluginTrigger' }), + }) + onClose() + refetch?.() + }, + onError: async (error: unknown) => { + const errorMessage = await parsePluginErrorMessage(error) || t('subscription.createFailed', { ns: 'pluginTrigger' }) + Toast.notify({ + type: 'error', + message: errorMessage, + }) + }, + }, + ) + }, [ + subscriptionBuilder, + detail?.provider, + createType, + autoCommonParametersSchema.length, + manualPropertiesSchema.length, + buildSubscription, + onClose, + refetch, + t, + ]) + + // Handle confirm (dispatch based on step) + const handleConfirm = useCallback(() => { + if (currentStep === ApiKeyStep.Verify) + handleVerify() + else + handleCreate() + }, [currentStep, handleVerify, handleCreate]) + + // Confirm button text + const confirmButtonText = useMemo(() => { + if (currentStep === ApiKeyStep.Verify) { + return isVerifyingCredentials + ? t('modal.common.verifying', { ns: 'pluginTrigger' }) + : t('modal.common.verify', { ns: 'pluginTrigger' }) + } + return isBuilding + ? t('modal.common.creating', { ns: 'pluginTrigger' }) + : t('modal.common.create', { ns: 'pluginTrigger' }) + }, [currentStep, isVerifyingCredentials, isBuilding, t]) + + return { + currentStep, + subscriptionBuilder, + isVerifyingCredentials, + isBuilding, + formRefs: { + manualPropertiesFormRef, + subscriptionFormRef, + autoCommonParametersFormRef, + apiKeyCredentialsFormRef, + }, + detail, + manualPropertiesSchema, + autoCommonParametersSchema, + apiKeyCredentialsSchema, + logData, + confirmButtonText, + handleVerify, + handleCreate, + handleConfirm, + handleManualPropertiesChange, + handleApiKeyCredentialsChange, + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts new file mode 100644 index 0000000000..de54a2b87c --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.spec.ts @@ -0,0 +1,719 @@ +import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { + AuthorizationStatusEnum, + ClientTypeEnum, + getErrorMessage, + useOAuthClientState, +} from './use-oauth-client-state' + +// ============================================================================ +// Mock Factory Functions +// ============================================================================ + +function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig { + return { + configured: true, + custom_configured: false, + custom_enabled: false, + system_configured: true, + redirect_uri: 'https://example.com/oauth/callback', + params: { + client_id: 'default-client-id', + client_secret: 'default-client-secret', + }, + oauth_client_schema: [ + { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown }, + { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown }, + ] as TriggerOAuthConfig['oauth_client_schema'], + ...overrides, + } +} + +function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBuilder> = {}): TriggerSubscriptionBuilder { + return { + id: 'builder-123', + name: 'Test Builder', + provider: 'test-provider', + credential_type: TriggerCredentialTypeEnum.Oauth2, + credentials: {}, + endpoint: 'https://example.com/callback', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, + } +} + +// ============================================================================ +// Mock Setup +// ============================================================================ + +const mockInitiateOAuth = vi.fn() +const mockVerifyBuilder = vi.fn() +const mockConfigureOAuth = vi.fn() +const mockDeleteOAuth = vi.fn() + +vi.mock('@/service/use-triggers', () => ({ + useInitiateTriggerOAuth: () => ({ + mutate: mockInitiateOAuth, + }), + useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ + mutate: mockVerifyBuilder, + }), + useConfigureTriggerOAuth: () => ({ + mutate: mockConfigureOAuth, + }), + useDeleteTriggerOAuth: () => ({ + mutate: mockDeleteOAuth, + }), +})) + +const mockOpenOAuthPopup = vi.fn() +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback), +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: (params: unknown) => mockToastNotify(params), + }, +})) + +// ============================================================================ +// Test Suites +// ============================================================================ + +describe('getErrorMessage', () => { + it('should extract message from Error instance', () => { + const error = new Error('Test error message') + expect(getErrorMessage(error, 'fallback')).toBe('Test error message') + }) + + it('should extract message from object with message property', () => { + const error = { message: 'Object error message' } + expect(getErrorMessage(error, 'fallback')).toBe('Object error message') + }) + + it('should return fallback when error is empty object', () => { + expect(getErrorMessage({}, 'fallback')).toBe('fallback') + }) + + it('should return fallback when error.message is not a string', () => { + expect(getErrorMessage({ message: 123 }, 'fallback')).toBe('fallback') + }) + + it('should return fallback when error.message is empty string', () => { + expect(getErrorMessage({ message: '' }, 'fallback')).toBe('fallback') + }) + + it('should return fallback when error is null', () => { + expect(getErrorMessage(null, 'fallback')).toBe('fallback') + }) + + it('should return fallback when error is undefined', () => { + expect(getErrorMessage(undefined, 'fallback')).toBe('fallback') + }) + + it('should return fallback when error is a primitive', () => { + expect(getErrorMessage('string error', 'fallback')).toBe('fallback') + expect(getErrorMessage(123, 'fallback')).toBe('fallback') + }) +}) + +describe('useOAuthClientState', () => { + const defaultParams = { + oauthConfig: createMockOAuthConfig(), + providerName: 'test-provider', + onClose: vi.fn(), + showOAuthCreateModal: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('Initial State', () => { + it('should default to Default client type when system_configured is true', () => { + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + expect(result.current.clientType).toBe(ClientTypeEnum.Default) + }) + + it('should default to Custom client type when system_configured is false', () => { + const config = createMockOAuthConfig({ system_configured: false }) + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + })) + + expect(result.current.clientType).toBe(ClientTypeEnum.Custom) + }) + + it('should have undefined authorizationStatus initially', () => { + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + expect(result.current.authorizationStatus).toBeUndefined() + }) + + it('should provide clientFormRef', () => { + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + expect(result.current.clientFormRef).toBeDefined() + expect(result.current.clientFormRef.current).toBeNull() + }) + }) + + describe('OAuth Client Schema', () => { + it('should compute schema with default values from params', () => { + const config = createMockOAuthConfig({ + params: { + client_id: 'my-client-id', + client_secret: 'my-secret', + }, + }) + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + })) + + expect(result.current.oauthClientSchema).toHaveLength(2) + expect(result.current.oauthClientSchema[0].default).toBe('my-client-id') + expect(result.current.oauthClientSchema[1].default).toBe('my-secret') + }) + + it('should return empty array when oauth_client_schema is empty', () => { + const config = createMockOAuthConfig({ + oauth_client_schema: [], + }) + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + })) + + expect(result.current.oauthClientSchema).toEqual([]) + }) + + it('should return empty array when params is undefined', () => { + const config = createMockOAuthConfig({ + params: undefined as unknown as TriggerOAuthConfig['params'], + }) + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + })) + + expect(result.current.oauthClientSchema).toEqual([]) + }) + + it('should preserve original schema default when param key not found', () => { + const config = createMockOAuthConfig({ + params: { + client_id: 'only-client-id', + client_secret: '', // empty + }, + oauth_client_schema: [ + { name: 'client_id', type: 'text-input' as unknown, required: true, label: {} as unknown, default: 'original-default' }, + { name: 'extra_field', type: 'text-input' as unknown, required: false, label: {} as unknown, default: 'extra-default' }, + ] as TriggerOAuthConfig['oauth_client_schema'], + }) + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + })) + + // client_id should be overridden + expect(result.current.oauthClientSchema[0].default).toBe('only-client-id') + // extra_field should keep original default since key not in params + expect(result.current.oauthClientSchema[1].default).toBe('extra-default') + }) + }) + + describe('Confirm Button Text', () => { + it('should show saveAndAuth text by default', () => { + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + expect(result.current.confirmButtonText).toBe('plugin.auth.saveAndAuth') + }) + + it('should show authorizing text when status is Pending', async () => { + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation(() => { + // Don't resolve - stays pending + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + await waitFor(() => { + expect(result.current.confirmButtonText).toBe('pluginTrigger.modal.common.authorizing') + }) + }) + }) + + describe('setClientType', () => { + it('should update client type when called', () => { + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.setClientType(ClientTypeEnum.Custom) + }) + + expect(result.current.clientType).toBe(ClientTypeEnum.Custom) + }) + + it('should toggle between client types', () => { + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.setClientType(ClientTypeEnum.Custom) + }) + expect(result.current.clientType).toBe(ClientTypeEnum.Custom) + + act(() => { + result.current.setClientType(ClientTypeEnum.Default) + }) + expect(result.current.clientType).toBe(ClientTypeEnum.Default) + }) + }) + + describe('handleRemove', () => { + it('should call deleteOAuth with provider name', () => { + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleRemove() + }) + + expect(mockDeleteOAuth).toHaveBeenCalledWith( + 'test-provider', + expect.any(Object), + ) + }) + + it('should call onClose and show success toast on success', () => { + mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => onSuccess()) + + const onClose = vi.fn() + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + onClose, + })) + + act(() => { + result.current.handleRemove() + }) + + expect(onClose).toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'pluginTrigger.modal.oauth.remove.success', + }) + }) + + it('should show error toast with error message on failure', () => { + mockDeleteOAuth.mockImplementation((provider, { onError }) => { + onError(new Error('Delete failed')) + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleRemove() + }) + + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'Delete failed', + }) + }) + }) + + describe('handleSave', () => { + it('should call configureOAuth with enabled: false for Default type', () => { + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(false) + }) + + expect(mockConfigureOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'test-provider', + enabled: false, + }), + expect.any(Object), + ) + }) + + it('should call configureOAuth with enabled: true for Custom type', () => { + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + + const config = createMockOAuthConfig({ system_configured: false }) + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + oauthConfig: config, + })) + + // Mock the form ref + const mockFormRef = { + getFormValues: () => ({ + values: { client_id: 'new-id', client_secret: 'new-secret' }, + isCheckValidated: true, + }), + } + // @ts-expect-error - mocking ref + result.current.clientFormRef.current = mockFormRef + + act(() => { + result.current.handleSave(false) + }) + + expect(mockConfigureOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + }), + expect.any(Object), + ) + }) + + it('should show success toast and call onClose when needAuth is false', () => { + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + const onClose = vi.fn() + + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + onClose, + })) + + act(() => { + result.current.handleSave(false) + }) + + expect(onClose).toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'pluginTrigger.modal.oauth.save.success', + }) + }) + + it('should trigger authorization when needAuth is true', () => { + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { + onSuccess({ + authorization_url: 'https://oauth.example.com/authorize', + subscription_builder: createMockSubscriptionBuilder(), + }) + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + expect(mockInitiateOAuth).toHaveBeenCalledWith( + 'test-provider', + expect.any(Object), + ) + }) + }) + + describe('handleAuthorization', () => { + it('should set status to Pending and call initiateOAuth', () => { + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation(() => {}) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending) + expect(mockInitiateOAuth).toHaveBeenCalled() + }) + + it('should open OAuth popup on success', () => { + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { + onSuccess({ + authorization_url: 'https://oauth.example.com/authorize', + subscription_builder: createMockSubscriptionBuilder(), + }) + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + expect(mockOpenOAuthPopup).toHaveBeenCalledWith( + 'https://oauth.example.com/authorize', + expect.any(Function), + ) + }) + + it('should set status to Failed and show error toast on error', () => { + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onError }) => { + onError(new Error('OAuth failed')) + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Failed) + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'pluginTrigger.modal.oauth.authorization.authFailed', + }) + }) + + it('should call onClose and showOAuthCreateModal on callback success', () => { + const onClose = vi.fn() + const showOAuthCreateModal = vi.fn() + const builder = createMockSubscriptionBuilder() + + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { + onSuccess({ + authorization_url: 'https://oauth.example.com/authorize', + subscription_builder: builder, + }) + }) + mockOpenOAuthPopup.mockImplementation((url, callback) => { + callback({ success: true }) + }) + + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + onClose, + showOAuthCreateModal, + })) + + act(() => { + result.current.handleSave(true) + }) + + expect(onClose).toHaveBeenCalled() + expect(showOAuthCreateModal).toHaveBeenCalledWith(builder) + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'pluginTrigger.modal.oauth.authorization.authSuccess', + }) + }) + + it('should not call callbacks when OAuth callback returns falsy', () => { + const onClose = vi.fn() + const showOAuthCreateModal = vi.fn() + + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { + onSuccess({ + authorization_url: 'https://oauth.example.com/authorize', + subscription_builder: createMockSubscriptionBuilder(), + }) + }) + mockOpenOAuthPopup.mockImplementation((url, callback) => { + callback(null) + }) + + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + onClose, + showOAuthCreateModal, + })) + + act(() => { + result.current.handleSave(true) + }) + + expect(onClose).not.toHaveBeenCalled() + expect(showOAuthCreateModal).not.toHaveBeenCalled() + }) + }) + + describe('Polling Effect', () => { + it('should start polling after authorization starts', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { + onSuccess({ + authorization_url: 'https://oauth.example.com/authorize', + subscription_builder: createMockSubscriptionBuilder(), + }) + }) + mockVerifyBuilder.mockImplementation((params, { onSuccess }) => { + onSuccess({ verified: false }) + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + // Advance timer to trigger first poll + await act(async () => { + vi.advanceTimersByTime(3000) + }) + + expect(mockVerifyBuilder).toHaveBeenCalled() + + vi.useRealTimers() + }) + + it('should set status to Success when verified', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { + onSuccess({ + authorization_url: 'https://oauth.example.com/authorize', + subscription_builder: createMockSubscriptionBuilder(), + }) + }) + mockVerifyBuilder.mockImplementation((params, { onSuccess }) => { + onSuccess({ verified: true }) + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + await act(async () => { + vi.advanceTimersByTime(3000) + }) + + await waitFor(() => { + expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Success) + }) + + vi.useRealTimers() + }) + + it('should continue polling on error', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { + onSuccess({ + authorization_url: 'https://oauth.example.com/authorize', + subscription_builder: createMockSubscriptionBuilder(), + }) + }) + mockVerifyBuilder.mockImplementation((params, { onError }) => { + onError(new Error('Verify failed')) + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + await act(async () => { + vi.advanceTimersByTime(3000) + }) + + expect(mockVerifyBuilder).toHaveBeenCalled() + // Status should still be Pending + expect(result.current.authorizationStatus).toBe(AuthorizationStatusEnum.Pending) + + vi.useRealTimers() + }) + + it('should stop polling when verified', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockConfigureOAuth.mockImplementation((params, { onSuccess }) => onSuccess()) + mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => { + onSuccess({ + authorization_url: 'https://oauth.example.com/authorize', + subscription_builder: createMockSubscriptionBuilder(), + }) + }) + mockVerifyBuilder.mockImplementation((params, { onSuccess }) => { + onSuccess({ verified: true }) + }) + + const { result } = renderHook(() => useOAuthClientState(defaultParams)) + + act(() => { + result.current.handleSave(true) + }) + + // First poll - should verify + await act(async () => { + vi.advanceTimersByTime(3000) + }) + + expect(mockVerifyBuilder).toHaveBeenCalledTimes(1) + + // Second poll - should not happen as interval is cleared + await act(async () => { + vi.advanceTimersByTime(3000) + }) + + // Still only 1 call because polling stopped + expect(mockVerifyBuilder).toHaveBeenCalledTimes(1) + + vi.useRealTimers() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined oauthConfig', () => { + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + oauthConfig: undefined, + })) + + expect(result.current.clientType).toBe(ClientTypeEnum.Custom) + expect(result.current.oauthClientSchema).toEqual([]) + }) + + it('should handle empty providerName', () => { + const { result } = renderHook(() => useOAuthClientState({ + ...defaultParams, + providerName: '', + })) + + // Should not throw + expect(result.current.clientType).toBe(ClientTypeEnum.Default) + }) + }) +}) + +describe('Enum Exports', () => { + it('should export AuthorizationStatusEnum', () => { + expect(AuthorizationStatusEnum.Pending).toBe('pending') + expect(AuthorizationStatusEnum.Success).toBe('success') + expect(AuthorizationStatusEnum.Failed).toBe('failed') + }) + + it('should export ClientTypeEnum', () => { + expect(ClientTypeEnum.Default).toBe('default') + expect(ClientTypeEnum.Custom).toBe('custom') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts new file mode 100644 index 0000000000..6a551051e2 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts @@ -0,0 +1,241 @@ +'use client' +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Toast from '@/app/components/base/toast' +import { openOAuthPopup } from '@/hooks/use-oauth' +import { + useConfigureTriggerOAuth, + useDeleteTriggerOAuth, + useInitiateTriggerOAuth, + useVerifyAndUpdateTriggerSubscriptionBuilder, +} from '@/service/use-triggers' + +export enum AuthorizationStatusEnum { + Pending = 'pending', + Success = 'success', + Failed = 'failed', +} + +export enum ClientTypeEnum { + Default = 'default', + Custom = 'custom', +} + +const POLL_INTERVAL_MS = 3000 + +// Extract error message from various error formats +export const getErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof Error && error.message) + return error.message + if (typeof error === 'object' && error && 'message' in error) { + const message = (error as { message?: string }).message + if (typeof message === 'string' && message) + return message + } + return fallback +} + +type UseOAuthClientStateParams = { + oauthConfig?: TriggerOAuthConfig + providerName: string + onClose: () => void + showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void +} + +type UseOAuthClientStateReturn = { + // State + clientType: ClientTypeEnum + setClientType: (type: ClientTypeEnum) => void + authorizationStatus: AuthorizationStatusEnum | undefined + + // Refs + clientFormRef: React.RefObject<FormRefObject | null> + + // Computed values + oauthClientSchema: TriggerOAuthConfig['oauth_client_schema'] + confirmButtonText: string + + // Handlers + handleAuthorization: () => void + handleRemove: () => void + handleSave: (needAuth: boolean) => void +} + +export const useOAuthClientState = ({ + oauthConfig, + providerName, + onClose, + showOAuthCreateModal, +}: UseOAuthClientStateParams): UseOAuthClientStateReturn => { + const { t } = useTranslation() + + // State management + const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>() + const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>() + const [clientType, setClientType] = useState<ClientTypeEnum>( + oauthConfig?.system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom, + ) + + const clientFormRef = useRef<FormRefObject>(null) + + // Mutations + const { mutate: initiateOAuth } = useInitiateTriggerOAuth() + const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder() + const { mutate: configureOAuth } = useConfigureTriggerOAuth() + const { mutate: deleteOAuth } = useDeleteTriggerOAuth() + + // Compute OAuth client schema with default values + const oauthClientSchema = useMemo(() => { + const { oauth_client_schema, params } = oauthConfig || {} + if (!oauth_client_schema?.length || !params) + return [] + + const paramKeys = Object.keys(params) + return oauth_client_schema.map(schema => ({ + ...schema, + default: paramKeys.includes(schema.name) ? params[schema.name] : schema.default, + })) + }, [oauthConfig]) + + // Compute confirm button text based on authorization status + const confirmButtonText = useMemo(() => { + if (authorizationStatus === AuthorizationStatusEnum.Pending) + return t('modal.common.authorizing', { ns: 'pluginTrigger' }) + if (authorizationStatus === AuthorizationStatusEnum.Success) + return t('modal.oauth.authorization.waitingJump', { ns: 'pluginTrigger' }) + return t('auth.saveAndAuth', { ns: 'plugin' }) + }, [authorizationStatus, t]) + + // Authorization handler + const handleAuthorization = useCallback(() => { + setAuthorizationStatus(AuthorizationStatusEnum.Pending) + initiateOAuth(providerName, { + onSuccess: (response) => { + setSubscriptionBuilder(response.subscription_builder) + openOAuthPopup(response.authorization_url, (callbackData) => { + if (!callbackData) + return + Toast.notify({ + type: 'success', + message: t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }), + }) + onClose() + showOAuthCreateModal(response.subscription_builder) + }) + }, + onError: () => { + setAuthorizationStatus(AuthorizationStatusEnum.Failed) + Toast.notify({ + type: 'error', + message: t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }), + }) + }, + }) + }, [providerName, initiateOAuth, onClose, showOAuthCreateModal, t]) + + // Remove handler + const handleRemove = useCallback(() => { + deleteOAuth(providerName, { + onSuccess: () => { + onClose() + Toast.notify({ + type: 'success', + message: t('modal.oauth.remove.success', { ns: 'pluginTrigger' }), + }) + }, + onError: (error: unknown) => { + Toast.notify({ + type: 'error', + message: getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })), + }) + }, + }) + }, [providerName, deleteOAuth, onClose, t]) + + // Save handler + const handleSave = useCallback((needAuth: boolean) => { + const isCustom = clientType === ClientTypeEnum.Custom + const params: ConfigureTriggerOAuthPayload = { + provider: providerName, + enabled: isCustom, + } + + if (isCustom && oauthClientSchema?.length) { + const clientFormValues = clientFormRef.current?.getFormValues({}) as { + values: TriggerOAuthClientParams + isCheckValidated: boolean + } | undefined + // Handle missing ref or form values + if (!clientFormValues || !clientFormValues.isCheckValidated) + return + const clientParams = { ...clientFormValues.values } + // Preserve hidden values if unchanged + if (clientParams.client_id === oauthConfig?.params.client_id) + clientParams.client_id = '[__HIDDEN__]' + if (clientParams.client_secret === oauthConfig?.params.client_secret) + clientParams.client_secret = '[__HIDDEN__]' + params.client_params = clientParams + } + + configureOAuth(params, { + onSuccess: () => { + if (needAuth) { + handleAuthorization() + return + } + onClose() + Toast.notify({ + type: 'success', + message: t('modal.oauth.save.success', { ns: 'pluginTrigger' }), + }) + }, + }) + }, [clientType, providerName, oauthClientSchema, oauthConfig?.params, configureOAuth, handleAuthorization, onClose, t]) + + // Polling effect for authorization verification + useEffect(() => { + const shouldPoll = providerName + && subscriptionBuilder + && authorizationStatus === AuthorizationStatusEnum.Pending + + if (!shouldPoll) + return + + const pollInterval = setInterval(() => { + verifyBuilder( + { + provider: providerName, + subscriptionBuilderId: subscriptionBuilder.id, + }, + { + onSuccess: (response) => { + if (response.verified) { + setAuthorizationStatus(AuthorizationStatusEnum.Success) + clearInterval(pollInterval) + } + }, + onError: () => { + // Continue polling on error - auth might still be in progress + }, + }, + ) + }, POLL_INTERVAL_MS) + + return () => clearInterval(pollInterval) + }, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName]) + + return { + clientType, + setClientType, + authorizationStatus, + clientFormRef, + oauthClientSchema, + confirmButtonText, + handleAuthorization, + handleRemove, + handleSave, + } +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx index 0ad6bc364e..8520d7e2e9 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx @@ -6,9 +6,6 @@ import { SupportedCreationMethods } from '@/app/components/plugins/types' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from './index' -// ==================== Mock Setup ==================== - -// Mock shared state for portal let mockPortalOpenState = false vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ @@ -36,21 +33,18 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ }, })) -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), }, })) -// Mock zustand store let mockStoreDetail: SimpleDetail | undefined vi.mock('../../store', () => ({ usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) => selector({ detail: mockStoreDetail }), })) -// Mock subscription list hook const mockSubscriptions: TriggerSubscription[] = [] const mockRefetch = vi.fn() vi.mock('../use-subscription-list', () => ({ @@ -60,7 +54,6 @@ vi.mock('../use-subscription-list', () => ({ }), })) -// Mock trigger service hooks let mockProviderInfo: { data: TriggerProviderApiEntity | undefined } = { data: undefined } let mockOAuthConfig: { data: TriggerOAuthConfig | undefined, refetch: () => void } = { data: undefined, refetch: vi.fn() } const mockInitiateOAuth = vi.fn() @@ -73,14 +66,12 @@ vi.mock('@/service/use-triggers', () => ({ }), })) -// Mock OAuth popup vi.mock('@/hooks/use-oauth', () => ({ openOAuthPopup: vi.fn((url: string, callback: (data?: unknown) => void) => { callback({ success: true, subscriptionId: 'test-subscription' }) }), })) -// Mock child modals vi.mock('./common-modal', () => ({ CommonCreateModal: ({ createType, onClose, builder }: { createType: SupportedCreationMethods @@ -128,7 +119,6 @@ vi.mock('./oauth-client', () => ({ ), })) -// Mock CustomSelect vi.mock('@/app/components/base/select/custom', () => ({ default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: { options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }> @@ -160,11 +150,6 @@ vi.mock('@/app/components/base/select/custom', () => ({ ), })) -// ==================== Test Utilities ==================== - -/** - * Factory function to create a TriggerProviderApiEntity with defaults - */ const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): TriggerProviderApiEntity => ({ author: 'test-author', name: 'test-provider', @@ -179,9 +164,6 @@ const createProviderInfo = (overrides: Partial<TriggerProviderApiEntity> = {}): ...overrides, }) -/** - * Factory function to create a TriggerOAuthConfig with defaults - */ const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig => ({ configured: false, custom_configured: false, @@ -196,9 +178,6 @@ const createOAuthConfig = (overrides: Partial<TriggerOAuthConfig> = {}): Trigger ...overrides, }) -/** - * Factory function to create a SimpleDetail with defaults - */ const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({ plugin_id: 'test-plugin', name: 'Test Plugin', @@ -209,9 +188,6 @@ const createStoreDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail ...overrides, }) -/** - * Factory function to create a TriggerSubscription with defaults - */ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): TriggerSubscription => ({ id: 'test-subscription', name: 'Test Subscription', @@ -225,16 +201,10 @@ const createSubscription = (overrides: Partial<TriggerSubscription> = {}): Trigg ...overrides, }) -/** - * Factory function to create default props - */ const createDefaultProps = (overrides: Partial<Parameters<typeof CreateSubscriptionButton>[0]> = {}) => ({ ...overrides, }) -/** - * Helper to set up mock data for testing - */ const setupMocks = (config: { providerInfo?: TriggerProviderApiEntity oauthConfig?: TriggerOAuthConfig @@ -249,8 +219,6 @@ const setupMocks = (config: { mockSubscriptions.push(...config.subscriptions) } -// ==================== Tests ==================== - describe('CreateSubscriptionButton', () => { beforeEach(() => { vi.clearAllMocks() @@ -258,7 +226,6 @@ describe('CreateSubscriptionButton', () => { setupMocks() }) - // ==================== Rendering Tests ==================== describe('Rendering', () => { it('should render null when supportedMethods is empty', () => { // Arrange @@ -322,7 +289,6 @@ describe('CreateSubscriptionButton', () => { }) }) - // ==================== Props Testing ==================== describe('Props', () => { it('should apply default buttonType as FULL_BUTTON', () => { // Arrange @@ -355,7 +321,6 @@ describe('CreateSubscriptionButton', () => { }) }) - // ==================== State Management ==================== describe('State Management', () => { it('should show CommonCreateModal when selectedCreateInfo is set', async () => { // Arrange @@ -474,7 +439,6 @@ describe('CreateSubscriptionButton', () => { }) }) - // ==================== Memoization Logic ==================== describe('Memoization - buttonTextMap', () => { it('should display correct button text for OAUTH method', () => { // Arrange 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 d119f42a13..eecaf165fb 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 @@ -2,7 +2,7 @@ import type { Option } from '@/app/components/base/select/custom' import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' import { RiAddLine, RiEqualizer2Line } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActionButton, ActionButtonState } from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' @@ -18,11 +18,7 @@ import { usePluginStore } from '../../store' import { useSubscriptionList } from '../use-subscription-list' import { CommonCreateModal } from './common-modal' import { OAuthClientSettingsModal } from './oauth-client' - -export enum CreateButtonType { - FULL_BUTTON = 'full-button', - ICON_BUTTON = 'icon-button', -} +import { CreateButtonType, DEFAULT_METHOD } from './types' type Props = { className?: string @@ -32,8 +28,6 @@ type Props = { const MAX_COUNT = 10 -export const DEFAULT_METHOD = 'default' - export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON, shape = 'square' }: Props) => { const { t } = useTranslation() const { subscriptions } = useSubscriptionList() @@ -43,7 +37,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU const detail = usePluginStore(state => state.detail) const { data: providerInfo } = useTriggerProviderInfo(detail?.provider || '') - const supportedMethods = providerInfo?.supported_creation_methods || [] + const supportedMethods = useMemo(() => providerInfo?.supported_creation_methods || [], [providerInfo?.supported_creation_methods]) const { data: oauthConfig, refetch: refetchOAuthConfig } = useTriggerOAuthConfig(detail?.provider || '', supportedMethods.includes(SupportedCreationMethods.OAUTH)) const { mutate: initiateOAuth } = useInitiateTriggerOAuth() @@ -63,11 +57,11 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU } }, [t]) - const onClickClientSettings = (e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => { + const onClickClientSettings = useCallback((e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>) => { e.stopPropagation() e.preventDefault() showClientSettingsModal() - } + }, [showClientSettingsModal]) const allOptions = useMemo(() => { const showCustomBadge = oauthConfig?.custom_enabled && oauthConfig?.custom_configured @@ -104,7 +98,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU show: supportedMethods.includes(SupportedCreationMethods.MANUAL), }, ] - }, [t, oauthConfig, supportedMethods, methodType]) + }, [t, oauthConfig, supportedMethods, methodType, onClickClientSettings]) const onChooseCreateType = async (type: SupportedCreationMethods) => { if (type === SupportedCreationMethods.OAUTH) { @@ -160,7 +154,7 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU <CustomSelect<Option & { show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }> options={allOptions.filter(option => option.show)} value={methodType} - onChange={value => onChooseCreateType(value as any)} + onChange={value => onChooseCreateType(value as SupportedCreationMethods)} containerProps={{ open: (methodType === DEFAULT_METHOD || (methodType === SupportedCreationMethods.OAUTH && supportedMethods.length === 1)) ? undefined : false, placement: 'bottom-start', @@ -254,3 +248,5 @@ export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BU </> ) } + +export { CreateButtonType, DEFAULT_METHOD } from './types' diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx index a842c63cfd..93cbbd518b 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx @@ -3,24 +3,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' - -// Import after mocks import { OAuthClientSettingsModal } from './oauth-client' -// ============================================================================ -// Type Definitions -// ============================================================================ - type PluginDetail = { plugin_id: string provider: string name: string } -// ============================================================================ -// Mock Factory Functions -// ============================================================================ - function createMockOAuthConfig(overrides: Partial<TriggerOAuthConfig> = {}): TriggerOAuthConfig { return { configured: true, @@ -64,18 +54,12 @@ function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBui } } -// ============================================================================ -// Mock Setup -// ============================================================================ - -// Mock plugin store const mockPluginDetail = createMockPluginDetail() const mockUsePluginStore = vi.fn(() => mockPluginDetail) vi.mock('../../store', () => ({ usePluginStore: () => mockUsePluginStore(), })) -// Mock service hooks const mockInitiateOAuth = vi.fn() const mockVerifyBuilder = vi.fn() const mockConfigureOAuth = vi.fn() @@ -96,13 +80,11 @@ vi.mock('@/service/use-triggers', () => ({ }), })) -// Mock OAuth popup const mockOpenOAuthPopup = vi.fn() vi.mock('@/hooks/use-oauth', () => ({ openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback), })) -// Mock toast const mockToastNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ default: { @@ -110,7 +92,6 @@ vi.mock('@/app/components/base/toast', () => ({ }, })) -// Mock clipboard API const mockClipboardWriteText = vi.fn() Object.assign(navigator, { clipboard: { @@ -118,7 +99,6 @@ Object.assign(navigator, { }, }) -// Mock Modal component vi.mock('@/app/components/base/modal/modal', () => ({ default: ({ children, @@ -161,24 +141,6 @@ vi.mock('@/app/components/base/modal/modal', () => ({ ), })) -// Mock Button component -vi.mock('@/app/components/base/button', () => ({ - default: ({ children, onClick, variant, className }: { - children: React.ReactNode - onClick?: () => void - variant?: string - className?: string - }) => ( - <button - data-testid={`button-${variant || 'default'}`} - onClick={onClick} - className={className} - > - {children} - </button> - ), -})) -// Configurable form mock values let mockFormValues: { values: Record<string, string>, isCheckValidated: boolean } = { values: { client_id: 'test-client-id', client_secret: 'test-client-secret' }, isCheckValidated: true, @@ -210,29 +172,6 @@ vi.mock('@/app/components/base/form/components/base', () => ({ }), })) -// Mock OptionCard component -vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({ - default: ({ title, onSelect, selected, className }: { - title: string - onSelect: () => void - selected: boolean - className?: string - }) => ( - <div - data-testid={`option-card-${title}`} - onClick={onSelect} - className={`${className} ${selected ? 'selected' : ''}`} - data-selected={selected} - > - {title} - </div> - ), -})) - -// ============================================================================ -// Test Suites -// ============================================================================ - describe('OAuthClientSettingsModal', () => { const defaultProps = { oauthConfig: createMockOAuthConfig(), @@ -244,7 +183,6 @@ describe('OAuthClientSettingsModal', () => { vi.clearAllMocks() mockUsePluginStore.mockReturnValue(mockPluginDetail) mockClipboardWriteText.mockResolvedValue(undefined) - // Reset form values to default setMockFormValues({ values: { client_id: 'test-client-id', client_secret: 'test-client-secret' }, isCheckValidated: true, @@ -265,8 +203,8 @@ describe('OAuthClientSettingsModal', () => { it('should render client type selector when system_configured is true', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument() - expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument() }) it('should not render client type selector when system_configured is false', () => { @@ -276,7 +214,7 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithoutSystemConfigured} />) - expect(screen.queryByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument() + expect(screen.queryByText('pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument() }) it('should render redirect URI info when custom client type is selected', () => { @@ -319,29 +257,29 @@ describe('OAuthClientSettingsModal', () => { it('should default to Default client type when system_configured is true', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default') - expect(defaultCard).toHaveAttribute('data-selected', 'true') + const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div') + expect(defaultCard).toHaveClass('border-[1.5px]') }) it('should switch to Custom client type when Custom card is clicked', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom') - fireEvent.click(customCard) + const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div') + fireEvent.click(customCard!) - expect(customCard).toHaveAttribute('data-selected', 'true') + expect(customCard).toHaveClass('border-[1.5px]') }) it('should switch back to Default client type when Default card is clicked', () => { render(<OAuthClientSettingsModal {...defaultProps} />) - const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom') - fireEvent.click(customCard) + const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div') + fireEvent.click(customCard!) - const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default') - fireEvent.click(defaultCard) + const defaultCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.default').closest('div') + fireEvent.click(defaultCard!) - expect(defaultCard).toHaveAttribute('data-selected', 'true') + expect(defaultCard).toHaveClass('border-[1.5px]') }) }) @@ -852,8 +790,8 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom - const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom') - fireEvent.click(customCard) + const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div') + fireEvent.click(customCard!) fireEvent.click(screen.getByTestId('modal-cancel')) @@ -1054,7 +992,7 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom') + const customCard = screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')! fireEvent.click(customCard) fireEvent.click(screen.getByTestId('modal-cancel')) @@ -1077,7 +1015,7 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')) + fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!) fireEvent.click(screen.getByTestId('modal-cancel')) @@ -1104,7 +1042,7 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')) + fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!) fireEvent.click(screen.getByTestId('modal-cancel')) @@ -1131,7 +1069,7 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')) + fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!) fireEvent.click(screen.getByTestId('modal-cancel')) @@ -1158,7 +1096,7 @@ describe('OAuthClientSettingsModal', () => { render(<OAuthClientSettingsModal {...defaultProps} />) // Switch to custom type - fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')) + fireEvent.click(screen.getByText('pluginTrigger.subscription.addType.options.oauth.custom').closest('div')!) fireEvent.click(screen.getByTestId('modal-cancel')) 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 25caf3b789..b7f9b8ebec 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 @@ -1,27 +1,17 @@ 'use client' -import type { FormRefObject } from '@/app/components/base/form/types' -import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' -import type { ConfigureTriggerOAuthPayload } from '@/service/use-triggers' +import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' import { RiClipboardLine, RiInformation2Fill, } from '@remixicon/react' -import * as React from 'react' -import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { BaseForm } from '@/app/components/base/form/components/base' import Modal from '@/app/components/base/modal/modal' import Toast from '@/app/components/base/toast' import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' -import { openOAuthPopup } from '@/hooks/use-oauth' -import { - useConfigureTriggerOAuth, - useDeleteTriggerOAuth, - useInitiateTriggerOAuth, - useVerifyAndUpdateTriggerSubscriptionBuilder, -} from '@/service/use-triggers' import { usePluginStore } from '../../store' +import { ClientTypeEnum, useOAuthClientState } from './hooks/use-oauth-client-state' type Props = { oauthConfig?: TriggerOAuthConfig @@ -29,169 +19,38 @@ type Props = { showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void } -enum AuthorizationStatusEnum { - Pending = 'pending', - Success = 'success', - Failed = 'failed', -} - -enum ClientTypeEnum { - Default = 'default', - Custom = 'custom', -} +const CLIENT_TYPE_OPTIONS = [ClientTypeEnum.Default, ClientTypeEnum.Custom] as const export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => { const { t } = useTranslation() const detail = usePluginStore(state => state.detail) - const { system_configured, params, oauth_client_schema } = oauthConfig || {} - const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>() - const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>() - - const [clientType, setClientType] = useState<ClientTypeEnum>(system_configured ? ClientTypeEnum.Default : ClientTypeEnum.Custom) - - const clientFormRef = React.useRef<FormRefObject>(null) - - const oauthClientSchema = useMemo(() => { - if (oauth_client_schema && oauth_client_schema.length > 0 && params) { - const oauthConfigPramaKeys = Object.keys(params || {}) - for (const schema of oauth_client_schema) { - if (oauthConfigPramaKeys.includes(schema.name)) - schema.default = params?.[schema.name] - } - return oauth_client_schema - } - return [] - }, [oauth_client_schema, params]) - const providerName = detail?.provider || '' - const { mutate: initiateOAuth } = useInitiateTriggerOAuth() - const { mutate: verifyBuilder } = useVerifyAndUpdateTriggerSubscriptionBuilder() - const { mutate: configureOAuth } = useConfigureTriggerOAuth() - const { mutate: deleteOAuth } = useDeleteTriggerOAuth() - const confirmButtonText = useMemo(() => { - if (authorizationStatus === AuthorizationStatusEnum.Pending) - return t('modal.common.authorizing', { ns: 'pluginTrigger' }) - if (authorizationStatus === AuthorizationStatusEnum.Success) - return t('modal.oauth.authorization.waitingJump', { ns: 'pluginTrigger' }) - return t('auth.saveAndAuth', { ns: 'plugin' }) - }, [authorizationStatus, t]) + const { + clientType, + setClientType, + clientFormRef, + oauthClientSchema, + confirmButtonText, + handleRemove, + handleSave, + } = useOAuthClientState({ + oauthConfig, + providerName, + onClose, + showOAuthCreateModal, + }) - const getErrorMessage = (error: unknown, fallback: string) => { - if (error instanceof Error && error.message) - return error.message - if (typeof error === 'object' && error && 'message' in error) { - const message = (error as { message?: string }).message - if (typeof message === 'string' && message) - return message - } - return fallback - } + const isCustomClient = clientType === ClientTypeEnum.Custom + const showRemoveButton = oauthConfig?.custom_enabled && oauthConfig?.params && isCustomClient + const showRedirectInfo = isCustomClient && oauthConfig?.redirect_uri + const showClientForm = isCustomClient && oauthClientSchema.length > 0 - const handleAuthorization = () => { - setAuthorizationStatus(AuthorizationStatusEnum.Pending) - initiateOAuth(providerName, { - onSuccess: (response) => { - setSubscriptionBuilder(response.subscription_builder) - openOAuthPopup(response.authorization_url, (callbackData) => { - if (callbackData) { - Toast.notify({ - type: 'success', - message: t('modal.oauth.authorization.authSuccess', { ns: 'pluginTrigger' }), - }) - onClose() - showOAuthCreateModal(response.subscription_builder) - } - }) - }, - onError: () => { - setAuthorizationStatus(AuthorizationStatusEnum.Failed) - Toast.notify({ - type: 'error', - message: t('modal.oauth.authorization.authFailed', { ns: 'pluginTrigger' }), - }) - }, - }) - } - - useEffect(() => { - if (providerName && subscriptionBuilder && authorizationStatus === AuthorizationStatusEnum.Pending) { - const pollInterval = setInterval(() => { - verifyBuilder( - { - provider: providerName, - subscriptionBuilderId: subscriptionBuilder.id, - }, - { - onSuccess: (response) => { - if (response.verified) { - setAuthorizationStatus(AuthorizationStatusEnum.Success) - clearInterval(pollInterval) - } - }, - onError: () => { - // Continue polling - auth might still be in progress - }, - }, - ) - }, 3000) - - return () => clearInterval(pollInterval) - } - }, [subscriptionBuilder, authorizationStatus, verifyBuilder, providerName, t]) - - const handleRemove = () => { - deleteOAuth(providerName, { - onSuccess: () => { - onClose() - Toast.notify({ - type: 'success', - message: t('modal.oauth.remove.success', { ns: 'pluginTrigger' }), - }) - }, - onError: (error: unknown) => { - Toast.notify({ - type: 'error', - message: getErrorMessage(error, t('modal.oauth.remove.failed', { ns: 'pluginTrigger' })), - }) - }, - }) - } - - const handleSave = (needAuth: boolean) => { - const isCustom = clientType === ClientTypeEnum.Custom - const params: ConfigureTriggerOAuthPayload = { - provider: providerName, - enabled: isCustom, - } - - if (isCustom) { - const clientFormValues = clientFormRef.current?.getFormValues({}) as { values: TriggerOAuthClientParams, isCheckValidated: boolean } - if (!clientFormValues.isCheckValidated) - return - const clientParams = clientFormValues.values - if (clientParams.client_id === oauthConfig?.params.client_id) - clientParams.client_id = '[__HIDDEN__]' - - if (clientParams.client_secret === oauthConfig?.params.client_secret) - clientParams.client_secret = '[__HIDDEN__]' - - params.client_params = clientParams - } - - configureOAuth(params, { - onSuccess: () => { - if (needAuth) { - handleAuthorization() - } - else { - onClose() - Toast.notify({ - type: 'success', - message: t('modal.oauth.save.success', { ns: 'pluginTrigger' }), - }) - } - }, + const handleCopyRedirectUri = () => { + navigator.clipboard.writeText(oauthConfig?.redirect_uri || '') + Toast.notify({ + type: 'success', + message: t('actionMsg.copySuccessfully', { ns: 'common' }), }) } @@ -208,25 +67,25 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate onClose={onClose} onCancel={() => handleSave(false)} onConfirm={() => handleSave(true)} - footerSlot={ - oauthConfig?.custom_enabled && oauthConfig?.params && clientType === ClientTypeEnum.Custom && ( - <div className="grow"> - <Button - variant="secondary" - className="text-components-button-destructive-secondary-text" - // disabled={disabled || doingAction || !editValues} - onClick={handleRemove} - > - {t('operation.remove', { ns: 'common' })} - </Button> - </div> - ) - } + footerSlot={showRemoveButton && ( + <div className="grow"> + <Button + variant="secondary" + className="text-components-button-destructive-secondary-text" + onClick={handleRemove} + > + {t('operation.remove', { ns: 'common' })} + </Button> + </div> + )} > - <div className="system-sm-medium mb-2 text-text-secondary">{t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })}</div> + <div className="system-sm-medium mb-2 text-text-secondary"> + {t('subscription.addType.options.oauth.clientTitle', { ns: 'pluginTrigger' })} + </div> + {oauthConfig?.system_configured && ( <div className="mb-4 flex w-full items-start justify-between gap-2"> - {[ClientTypeEnum.Default, ClientTypeEnum.Custom].map(option => ( + {CLIENT_TYPE_OPTIONS.map(option => ( <OptionCard key={option} title={t(`subscription.addType.options.oauth.${option}`, { ns: 'pluginTrigger' })} @@ -237,7 +96,8 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate ))} </div> )} - {clientType === ClientTypeEnum.Custom && oauthConfig?.redirect_uri && ( + + {showRedirectInfo && ( <div className="mb-4 flex items-start gap-3 rounded-xl bg-background-section-burn p-4"> <div className="rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg p-2 shadow-xs shadow-shadow-shadow-3"> <RiInformation2Fill className="h-5 w-5 shrink-0 text-text-accent" /> @@ -247,18 +107,12 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate {t('modal.oauthRedirectInfo', { ns: 'pluginTrigger' })} </div> <div className="system-sm-medium my-1.5 break-all leading-4"> - {oauthConfig.redirect_uri} + {oauthConfig?.redirect_uri} </div> <Button variant="secondary" size="small" - onClick={() => { - navigator.clipboard.writeText(oauthConfig.redirect_uri) - Toast.notify({ - type: 'success', - message: t('actionMsg.copySuccessfully', { ns: 'common' }), - }) - }} + onClick={handleCopyRedirectUri} > <RiClipboardLine className="mr-1 h-[14px] w-[14px]" /> {t('operation.copy', { ns: 'common' })} @@ -266,7 +120,8 @@ export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreate </div> </div> )} - {clientType === ClientTypeEnum.Custom && oauthClientSchema.length > 0 && ( + + {showClientForm && ( <BaseForm formSchemas={oauthClientSchema} ref={clientFormRef} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/types.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/types.ts new file mode 100644 index 0000000000..637846b606 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/types.ts @@ -0,0 +1,6 @@ +export enum CreateButtonType { + FULL_BUTTON = 'full-button', + ICON_BUTTON = 'icon-button', +} + +export const DEFAULT_METHOD = 'default' diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index e5ced085ff..9cfe1fd462 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -2445,11 +2445,6 @@ "count": 8 } }, - "app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel.tsx": { - "ts/no-explicit-any": { - "count": 8 - } - }, "app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2503,14 +2498,6 @@ "count": 2 } }, - "app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": { - "react-refresh/only-export-components": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx": { "ts/no-explicit-any": { "count": 1