feat: implement subscription editing modals and enhance subscription management

- Added `ApiKeyEditModal`, `OAuthEditModal`, and `ManualEditModal` for editing different types of subscriptions.
- Updated `EditModal` to conditionally render the appropriate modal based on the subscription's credential type.
- Introduced `useVerifyTriggerSubscription` hook for verifying subscription credentials.
- Enhanced form handling and validation for subscription properties and parameters across modals.
- Refactored existing components to integrate new editing functionality, improving user experience in subscription management.
This commit is contained in:
Harry
2025-12-20 12:29:51 +08:00
parent 883278b583
commit 3c009830b6
6 changed files with 539 additions and 10 deletions

View File

@@ -0,0 +1,323 @@
'use client'
import { BaseForm } from '@/app/components/base/form/components/base'
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast'
import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { useUpdateTriggerSubscription, useVerifyTriggerSubscription } from '@/service/use-triggers'
import { parsePluginErrorMessage } from '@/utils/error-parser'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
import { ReadmeShowType } from '../../../readme-panel/store'
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
type Props = {
onClose: () => void
subscription: TriggerSubscription
pluginDetail?: PluginDetail
}
enum EditStep {
EditCredentials = 'edit_credentials',
EditConfiguration = 'edit_configuration',
}
const normalizeFormType = (type: string): 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
case 'select':
return FormTypeEnum.select
default:
if (Object.values(FormTypeEnum).includes(type as FormTypeEnum))
return type as FormTypeEnum
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'}`}>
{isActive && (
<div className='h-1 w-1 rounded-full bg-state-accent-solid'></div>
)}
{text}
</div>
}
const MultiSteps = ({ currentStep }: { currentStep: EditStep }) => {
const { t } = useTranslation()
return <div className='mb-6 flex w-1/3 items-center gap-2'>
<StatusStep isActive={currentStep === EditStep.EditCredentials} text={t('pluginTrigger.modal.steps.verify')} />
<div className='h-px w-3 shrink-0 bg-divider-deep'></div>
<StatusStep isActive={currentStep === EditStep.EditConfiguration} text={t('pluginTrigger.modal.steps.configuration')} />
</div>
}
export const ApiKeyEditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { refetch } = useSubscriptionList()
const [currentStep, setCurrentStep] = useState<EditStep>(EditStep.EditCredentials)
const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription()
const { mutate: verifyCredentials, isPending: isVerifying } = useVerifyTriggerSubscription()
const parametersSchema = useMemo<ParametersSchema[]>(
() => detail?.declaration?.trigger?.subscription_constructor?.parameters || [],
[detail?.declaration?.trigger?.subscription_constructor?.parameters],
)
const rawApiKeyCredentialsSchema = detail?.declaration.trigger?.subscription_constructor?.credentials_schema || []
const apiKeyCredentialsSchema = useMemo(() => {
return rawApiKeyCredentialsSchema.map(schema => ({
...schema,
tooltip: schema.help,
}))
}, [rawApiKeyCredentialsSchema])
const basicFormRef = useRef<FormRefObject>(null)
const parametersFormRef = useRef<FormRefObject>(null)
const credentialsFormRef = useRef<FormRefObject>(null)
const handleVerifyCredentials = () => {
const credentialsFormValues = credentialsFormRef.current?.getFormValues({
needTransformWhenSecretFieldIsPristine: true,
}) || { values: {}, isCheckValidated: false }
if (!credentialsFormValues.isCheckValidated)
return
const credentials = credentialsFormValues.values
// Clear previous errors
if (Object.keys(credentials).length > 0) {
credentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
errors: [],
}])
}
verifyCredentials(
{
provider: subscription.provider,
subscriptionId: subscription.id,
credentials,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.modal.apiKey.verify.success'),
})
setCurrentStep(EditStep.EditConfiguration)
},
onError: async (error: any) => {
const errorMessage = await parsePluginErrorMessage(error) || t('pluginTrigger.modal.apiKey.verify.error')
if (Object.keys(credentials).length > 0) {
credentialsFormRef.current?.setFields([{
name: Object.keys(credentials)[0],
errors: [errorMessage],
}])
}
},
},
)
}
const handleUpdate = () => {
const basicFormValues = basicFormRef.current?.getFormValues({})
if (!basicFormValues?.isCheckValidated)
return
const name = basicFormValues.values.subscription_name as string
let parameters: Record<string, any> | undefined
if (parametersSchema.length > 0) {
const paramsFormValues = parametersFormRef.current?.getFormValues({
needTransformWhenSecretFieldIsPristine: true,
})
if (!paramsFormValues?.isCheckValidated)
return
parameters = Object.keys(paramsFormValues.values).length > 0 ? paramsFormValues.values : undefined
}
updateSubscription(
{
subscriptionId: subscription.id,
name,
parameters,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.subscription.list.item.actions.edit.success'),
})
refetch?.()
onClose()
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || t('pluginTrigger.subscription.list.item.actions.edit.error'),
})
},
},
)
}
const handleConfirm = () => {
if (currentStep === EditStep.EditCredentials)
handleVerifyCredentials()
else
handleUpdate()
}
const handleCredentialsChange = () => {
if (apiKeyCredentialsSchema.length > 0) {
credentialsFormRef.current?.setFields([{
name: apiKeyCredentialsSchema[0].name,
errors: [],
}])
}
}
const basicFormSchemas: FormSchema[] = useMemo(() => [
{
name: 'subscription_name',
label: t('pluginTrigger.modal.form.subscriptionName.label'),
placeholder: t('pluginTrigger.modal.form.subscriptionName.placeholder'),
type: FormTypeEnum.textInput,
required: true,
default: subscription.name,
},
{
name: 'callback_url',
label: t('pluginTrigger.modal.form.callbackUrl.label'),
placeholder: t('pluginTrigger.modal.form.callbackUrl.placeholder'),
type: FormTypeEnum.textInput,
required: false,
default: subscription.endpoint || '',
disabled: true,
tooltip: t('pluginTrigger.modal.form.callbackUrl.tooltip'),
showCopy: true,
},
], [t, subscription.name, subscription.endpoint])
const credentialsFormSchemas: FormSchema[] = useMemo(() => {
return apiKeyCredentialsSchema.map(schema => ({
...schema,
type: normalizeFormType(schema.type as string),
tooltip: schema.help,
default: subscription.credentials?.[schema.name] || schema.default,
}))
}, [apiKeyCredentialsSchema, subscription.credentials])
const parametersFormSchemas: FormSchema[] = useMemo(() => {
return parametersSchema.map((schema: ParametersSchema) => {
const normalizedType = normalizeFormType(schema.type as string)
return {
...schema,
type: normalizedType,
tooltip: schema.description,
default: subscription.parameters?.[schema.name] || schema.default,
dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect
? {
plugin_id: detail?.plugin_id || '',
provider: detail?.provider || '',
action: 'provider',
parameter: schema.name,
credential_id: subscription.id,
}
: undefined,
fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined,
labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined,
}
})
}, [parametersSchema, subscription.parameters, subscription.id, detail?.plugin_id, detail?.provider])
const getConfirmButtonText = () => {
if (currentStep === EditStep.EditCredentials)
return isVerifying ? t('pluginTrigger.modal.common.verifying') : t('pluginTrigger.modal.common.verify')
return isUpdating ? t('common.operation.saving') : t('common.operation.save')
}
return (
<Modal
title={t('pluginTrigger.subscription.list.item.actions.edit.title')}
confirmButtonText={getConfirmButtonText()}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isUpdating || isVerifying}
clickOutsideNotClose
wrapperClassName='!z-[101]'
bottomSlot={currentStep === EditStep.EditCredentials ? <EncryptedBottom /> : null}
>
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
)}
{/* Multi-step indicator */}
<MultiSteps currentStep={currentStep} />
{/* Step 1: Edit Credentials */}
{currentStep === EditStep.EditCredentials && (
<div className='mb-4'>
{credentialsFormSchemas.length > 0 && (
<BaseForm
formSchemas={credentialsFormSchemas}
ref={credentialsFormRef}
labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary'
formClassName='space-y-4'
preventDefaultSubmit={true}
onChange={handleCredentialsChange}
/>
)}
</div>
)}
{/* Step 2: Edit Configuration */}
{currentStep === EditStep.EditConfiguration && (
<div className='max-h-[70vh]'>
{/* Basic form: subscription name and callback URL */}
<BaseForm
formSchemas={basicFormSchemas}
ref={basicFormRef}
labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary'
formClassName='space-y-4 mb-4'
/>
{/* Parameters */}
{parametersFormSchemas.length > 0 && (
<BaseForm
formSchemas={parametersFormSchemas}
ref={parametersFormRef}
labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary'
formClassName='space-y-4'
/>
)}
</div>
)}
</Modal>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { ManualEditModal } from './manual-edit-modal'
import { OAuthEditModal } from './oauth-edit-modal'
import { ApiKeyEditModal } from './apikey-edit-modal'
type Props = {
onClose: () => void
subscription: TriggerSubscription
pluginDetail?: PluginDetail
}
export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const credentialType = subscription.credential_type
switch (credentialType) {
case TriggerCredentialTypeEnum.Unauthorized:
return <ManualEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} />
case TriggerCredentialTypeEnum.Oauth2:
return <OAuthEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} />
case TriggerCredentialTypeEnum.ApiKey:
return <ApiKeyEditModal onClose={onClose} subscription={subscription} pluginDetail={pluginDetail} />
default:
return null
}
}

View File

@@ -10,9 +10,9 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore } from '../store'
import { useSubscriptionList } from './use-subscription-list'
import { ReadmeShowType } from '../../readme-panel/store'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
import { ReadmeShowType } from '../../../readme-panel/store'
type Props = {
onClose: () => void
@@ -20,7 +20,6 @@ type Props = {
pluginDetail?: PluginDetail
}
// Normalize backend type to FormTypeEnum
const normalizeFormType = (type: string): FormTypeEnum => {
switch (type) {
case 'string':
@@ -43,7 +42,7 @@ const normalizeFormType = (type: string): FormTypeEnum => {
}
}
export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
export const ManualEditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { refetch } = useSubscriptionList()
@@ -54,12 +53,10 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
() => detail?.declaration?.trigger?.subscription_schema || [],
[detail?.declaration?.trigger?.subscription_schema],
)
const formRef = useRef<FormRefObject>(null)
const handleConfirm = () => {
// Use needTransformWhenSecretFieldIsPristine to handle secret fields
// When secret field is not modified, it will be transformed to '[__HIDDEN__]'
// Backend will preserve original value when receiving '[__HIDDEN__]'
const formValues = formRef.current?.getFormValues({
needTransformWhenSecretFieldIsPristine: true,
})
@@ -68,7 +65,7 @@ export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const name = formValues.values.subscription_name as string
// Extract properties values (exclude subscription_name and callback_url)
// Extract properties (exclude subscription_name and callback_url)
const properties = { ...formValues.values }
delete properties.subscription_name
delete properties.callback_url

View File

@@ -0,0 +1,162 @@
'use client'
import { BaseForm } from '@/app/components/base/form/components/base'
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast'
import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types'
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { useUpdateTriggerSubscription } from '@/service/use-triggers'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore } from '../../store'
import { useSubscriptionList } from '../use-subscription-list'
import { ReadmeShowType } from '../../../readme-panel/store'
type Props = {
onClose: () => void
subscription: TriggerSubscription
pluginDetail?: PluginDetail
}
const normalizeFormType = (type: string): 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
case 'select':
return FormTypeEnum.select
default:
if (Object.values(FormTypeEnum).includes(type as FormTypeEnum))
return type as FormTypeEnum
return FormTypeEnum.textInput
}
}
export const OAuthEditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { refetch } = useSubscriptionList()
const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription()
const parametersSchema = useMemo<ParametersSchema[]>(
() => detail?.declaration?.trigger?.subscription_constructor?.parameters || [],
[detail?.declaration?.trigger?.subscription_constructor?.parameters],
)
const formRef = useRef<FormRefObject>(null)
const handleConfirm = () => {
const formValues = formRef.current?.getFormValues({
needTransformWhenSecretFieldIsPristine: true,
})
if (!formValues?.isCheckValidated)
return
const name = formValues.values.subscription_name as string
// Extract parameters (exclude subscription_name and callback_url)
const parameters = { ...formValues.values }
delete parameters.subscription_name
delete parameters.callback_url
updateSubscription(
{
subscriptionId: subscription.id,
name,
parameters: Object.keys(parameters).length > 0 ? parameters : undefined,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.subscription.list.item.actions.edit.success'),
})
refetch?.()
onClose()
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || t('pluginTrigger.subscription.list.item.actions.edit.error'),
})
},
},
)
}
const formSchemas: FormSchema[] = useMemo(() => [
{
name: 'subscription_name',
label: t('pluginTrigger.modal.form.subscriptionName.label'),
placeholder: t('pluginTrigger.modal.form.subscriptionName.placeholder'),
type: FormTypeEnum.textInput,
required: true,
default: subscription.name,
},
{
name: 'callback_url',
label: t('pluginTrigger.modal.form.callbackUrl.label'),
placeholder: t('pluginTrigger.modal.form.callbackUrl.placeholder'),
type: FormTypeEnum.textInput,
required: false,
default: subscription.endpoint || '',
disabled: true,
tooltip: t('pluginTrigger.modal.form.callbackUrl.tooltip'),
showCopy: true,
},
...parametersSchema.map((schema: ParametersSchema) => {
const normalizedType = normalizeFormType(schema.type as string)
return {
...schema,
type: normalizedType,
tooltip: schema.description,
default: subscription.parameters?.[schema.name] || schema.default,
dynamicSelectParams: normalizedType === FormTypeEnum.dynamicSelect
? {
plugin_id: detail?.plugin_id || '',
provider: detail?.provider || '',
action: 'provider',
parameter: schema.name,
credential_id: subscription.id,
}
: undefined,
fieldClassName: schema.type === FormTypeEnum.boolean ? 'flex items-center justify-between' : undefined,
labelClassName: schema.type === FormTypeEnum.boolean ? 'mb-0' : undefined,
}
}),
], [t, subscription.name, subscription.endpoint, subscription.parameters, subscription.id, parametersSchema, detail?.plugin_id, detail?.provider])
return (
<Modal
title={t('pluginTrigger.subscription.list.item.actions.edit.title')}
confirmButtonText={isUpdating ? t('common.operation.saving') : t('common.operation.save')}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isUpdating}
clickOutsideNotClose
wrapperClassName='!z-[101]'
>
{pluginDetail && (
<ReadmeEntrance pluginDetail={pluginDetail} showType={ReadmeShowType.modal} />
)}
<BaseForm
formSchemas={formSchemas}
ref={formRef}
labelClassName='system-sm-medium mb-2 flex items-center gap-1 text-text-primary'
formClassName='space-y-4'
/>
</Modal>
)
}

View File

@@ -12,7 +12,7 @@ import {
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { DeleteConfirm } from './delete-confirm'
import { EditModal } from './edit-modal'
import { EditModal } from './edit'
type Props = {
data: TriggerSubscription

View File

@@ -180,6 +180,24 @@ export const useVerifyAndUpdateTriggerSubscriptionBuilder = () => {
})
}
export const useVerifyTriggerSubscription = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'verify-subscription'],
mutationFn: (payload: {
provider: string;
subscriptionId: string;
credentials?: Record<string, any>;
}) => {
const { provider, subscriptionId, ...body } = payload
return post<{ verified: boolean }>(
`/workspaces/current/trigger-provider/${provider}/subscriptions/verify/${subscriptionId}`,
{ body },
{ silent: true },
)
},
})
}
export type BuildTriggerSubscriptionPayload = {
provider: string
subscriptionBuilderId: string
@@ -215,6 +233,7 @@ export type UpdateTriggerSubscriptionPayload = {
subscriptionId: string
name?: string
properties?: Record<string, any>
parameters?: Record<string, any>
}
export const useUpdateTriggerSubscription = () => {