feat: add API endpoint and model for updating trigger subscriptions

- Introduced `TriggerSubscriptionUpdateRequest` model for request validation.
- Implemented `TriggerSubscriptionUpdateApi` to handle subscription updates.
- Enhanced `TriggerProviderService` with `update_trigger_subscription` method for backend logic.
- Updated frontend components to support subscription editing functionality.
- Added new translations for subscription edit actions.
This commit is contained in:
Harry
2025-12-16 17:17:58 +08:00
parent 7695f9151c
commit ade3e24dd2
12 changed files with 346 additions and 16 deletions

View File

@@ -1,7 +1,9 @@
import logging
from typing import Any
from flask import make_response, redirect, request
from flask_restx import Resource, reqparse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden
@@ -32,6 +34,19 @@ from ..wraps import (
logger = logging.getLogger(__name__)
class TriggerSubscriptionUpdateRequest(BaseModel):
"""Request payload for updating a trigger subscription"""
name: str | None = Field(default=None, description="Subscription instance name")
properties: dict[str, Any] | None = Field(default=None, description="Subscription properties")
console_ns.schema_model(
TriggerSubscriptionUpdateRequest.__name__,
TriggerSubscriptionUpdateRequest.model_json_schema(ref_template="#/definitions/{model}"),
)
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/icon")
class TriggerProviderIconApi(Resource):
@setup_required
@@ -289,6 +304,38 @@ class TriggerSubscriptionBuilderBuildApi(Resource):
raise ValueError(str(e)) from e
@console_ns.route(
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/update",
)
class TriggerSubscriptionUpdateApi(Resource):
@console_ns.expect(console_ns.models[TriggerSubscriptionUpdateRequest.__name__])
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
def post(self, subscription_id: str):
"""Update a subscription instance"""
user = current_user
assert user.current_tenant_id is not None
args = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
try:
return jsonable_encoder(
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
properties=args.properties,
)
)
except ValueError as e:
raise BadRequest(str(e))
except Exception as e:
logger.exception("Error updating subscription", exc_info=e)
raise
@console_ns.route(
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/delete",
)

View File

@@ -67,12 +67,16 @@ def create_trigger_provider_encrypter_for_subscription(
def delete_cache_for_subscription(tenant_id: str, provider_id: str, subscription_id: str):
cache = TriggerProviderCredentialsCache(
TriggerProviderCredentialsCache(
tenant_id=tenant_id,
provider_id=provider_id,
credential_id=subscription_id,
)
cache.delete()
).delete()
TriggerProviderPropertiesCache(
tenant_id=tenant_id,
provider_id=provider_id,
subscription_id=subscription_id,
).delete()
def create_trigger_provider_encrypter_for_properties(

View File

@@ -209,6 +209,77 @@ class TriggerProviderService:
logger.exception("Failed to add trigger provider")
raise ValueError(str(e))
@classmethod
def update_trigger_subscription(
cls,
tenant_id: str,
subscription_id: str,
name: str | None = None,
properties: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
"""
Update an existing trigger subscription.
:param tenant_id: Tenant ID
:param subscription_id: Subscription instance ID
:param name: Optional new name for this subscription
:param properties: Optional new properties
:return: Success response with updated subscription info
"""
with Session(db.engine, expire_on_commit=False) as session:
# Use distributed lock to prevent race conditions on the same subscription
lock_key = f"trigger_subscription_update_lock:{tenant_id}_{subscription_id}"
with redis_client.lock(lock_key, timeout=20):
subscription: TriggerSubscription | None = (
session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first()
)
if not subscription:
raise ValueError(f"Trigger subscription {subscription_id} not found")
provider_id = TriggerProviderID(subscription.provider_id)
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
# Check for name uniqueness if name is being updated
if name is not None and name != subscription.name:
existing = (
session.query(TriggerSubscription)
.filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name)
.first()
)
if existing:
raise ValueError(f"Subscription name '{name}' already exists for this provider")
subscription.name = name
# Update properties if provided
if properties is not None:
properties_encrypter, properties_cache = create_provider_encrypter(
tenant_id=tenant_id,
config=provider_controller.get_properties_schema(),
cache=NoOpProviderCredentialCache(),
)
# Handle hidden values - preserve original encrypted values
original_properties = properties_encrypter.decrypt(subscription.properties)
new_properties: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else original_properties.get(key, UNKNOWN_VALUE)
for key, value in properties.items()
}
subscription.properties = dict(properties_encrypter.encrypt(new_properties))
properties_cache.delete()
session.commit()
# Clear subscription cache
delete_cache_for_subscription(
tenant_id=tenant_id,
provider_id=subscription.provider_id,
subscription_id=subscription.id,
)
return {
"result": "success",
"id": str(subscription.id),
}
@classmethod
def get_subscription_by_id(cls, tenant_id: str, subscription_id: str | None = None) -> TriggerSubscription | None:
"""

View File

@@ -453,11 +453,12 @@ class TriggerSubscriptionBuilderService:
if not subscription_builder:
return None
# response to validation endpoint
controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=subscription_builder.tenant_id, provider_id=TriggerProviderID(subscription_builder.provider_id)
)
try:
# response to validation endpoint
controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider(
tenant_id=subscription_builder.tenant_id,
provider_id=TriggerProviderID(subscription_builder.provider_id),
)
dispatch_response: TriggerDispatchResponse = controller.dispatch(
request=request,
subscription=subscription_builder.to_subscription(),

View File

@@ -66,7 +66,7 @@ const PluginDetailPanel: FC<Props> = ({
<div className='flex-1'>
{detail.declaration.category === PluginCategoryEnum.trigger && (
<>
<SubscriptionList />
<SubscriptionList pluginDetail={detail} />
<TriggerEventsList />
</>
)}

View File

@@ -0,0 +1,151 @@
'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
}
// Normalize backend type to FormTypeEnum
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 EditModal = ({ onClose, subscription, pluginDetail }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const { refetch } = useSubscriptionList()
const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription()
const propertiesSchema = useMemo<ParametersSchema[]>(
() => 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,
})
if (!formValues?.isCheckValidated)
return
const name = formValues.values.subscription_name as string
// Extract properties values (exclude subscription_name and callback_url)
const properties = { ...formValues.values }
delete properties.subscription_name
delete properties.callback_url
updateSubscription(
{
subscriptionId: subscription.id,
name,
properties: Object.keys(properties).length > 0 ? properties : 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,
},
...propertiesSchema.map((schema: ParametersSchema) => ({
...schema,
type: normalizeFormType(schema.type as string),
tooltip: schema.description,
default: (subscription.properties as Record<string, any>)?.[schema.name] ?? schema.default,
})),
], [t, subscription.name, subscription.endpoint, subscription.properties, propertiesSchema])
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

@@ -1,5 +1,6 @@
import { withErrorBoundary } from '@/app/components/base/error-boundary'
import Loading from '@/app/components/base/loading'
import type { PluginDetail } from '@/app/components/plugins/types'
import { SubscriptionListView } from './list-view'
import { SubscriptionSelectorView } from './selector-view'
import { useSubscriptionList } from './use-subscription-list'
@@ -18,6 +19,7 @@ type SubscriptionListProps = {
mode?: SubscriptionListMode
selectedId?: string
onSelect?: (v: SimpleSubscription, callback?: () => void) => void
pluginDetail?: PluginDetail
}
export { SubscriptionSelectorEntry } from './selector-entry'
@@ -26,6 +28,7 @@ export const SubscriptionList = withErrorBoundary(({
mode = SubscriptionListMode.PANEL,
selectedId,
onSelect,
pluginDetail,
}: SubscriptionListProps) => {
const { isLoading, refetch } = useSubscriptionList()
if (isLoading) {
@@ -47,5 +50,5 @@ export const SubscriptionList = withErrorBoundary(({
)
}
return <SubscriptionListView />
return <SubscriptionListView pluginDetail={pluginDetail} />
})

View File

@@ -1,5 +1,6 @@
'use client'
import Tooltip from '@/app/components/base/tooltip'
import type { PluginDetail } from '@/app/components/plugins/types'
import cn from '@/utils/classnames'
import React from 'react'
import { useTranslation } from 'react-i18next'
@@ -9,10 +10,12 @@ import { useSubscriptionList } from './use-subscription-list'
type SubscriptionListViewProps = {
showTopBorder?: boolean
pluginDetail?: PluginDetail
}
export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
showTopBorder = false,
pluginDetail,
}) => {
const { t } = useTranslation()
const { subscriptions } = useSubscriptionList()
@@ -41,6 +44,7 @@ export const SubscriptionListView: React.FC<SubscriptionListViewProps> = ({
<SubscriptionCard
key={subscription.id}
data={subscription}
pluginDetail={pluginDetail}
/>
))}
</div>

View File

@@ -1,26 +1,34 @@
'use client'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import cn from '@/utils/classnames'
import {
RiDeleteBinLine,
RiEditLine,
RiWebhookLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { DeleteConfirm } from './delete-confirm'
import { EditModal } from './edit-modal'
type Props = {
data: TriggerSubscription
pluginDetail?: PluginDetail
}
const SubscriptionCard = ({ data }: Props) => {
const SubscriptionCard = ({ data, pluginDetail }: Props) => {
const { t } = useTranslation()
const [isShowDeleteModal, {
setTrue: showDeleteModal,
setFalse: hideDeleteModal,
}] = useBoolean(false)
const [isShowEditModal, {
setTrue: showEditModal,
setFalse: hideEditModal,
}] = useBoolean(false)
return (
<>
@@ -40,12 +48,20 @@ const SubscriptionCard = ({ data }: Props) => {
</span>
</div>
<ActionButton
onClick={showDeleteModal}
className='subscription-delete-btn hidden transition-colors hover:bg-state-destructive-hover hover:text-text-destructive group-hover:block'
>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
<div className='hidden items-center gap-1 group-hover:flex'>
<ActionButton
onClick={showEditModal}
className='transition-colors hover:bg-state-base-hover'
>
<RiEditLine className='h-4 w-4' />
</ActionButton>
<ActionButton
onClick={showDeleteModal}
className='subscription-delete-btn transition-colors hover:bg-state-destructive-hover hover:text-text-destructive'
>
<RiDeleteBinLine className='h-4 w-4' />
</ActionButton>
</div>
</div>
<div className='mt-1 flex items-center justify-between'>
@@ -78,6 +94,14 @@ const SubscriptionCard = ({ data }: Props) => {
workflowsInUse={data.workflows_in_use}
/>
)}
{isShowEditModal && (
<EditModal
onClose={hideEditModal}
subscription={data}
pluginDetail={pluginDetail}
/>
)}
</>
)
}

View File

@@ -18,6 +18,7 @@ const translation = {
cancel: 'Cancel',
clear: 'Clear',
save: 'Save',
saving: 'Saving...',
yes: 'Yes',
no: 'No',
deleteConfirmTitle: 'Delete?',

View File

@@ -30,6 +30,11 @@ const translation = {
unauthorized: 'Manual',
},
actions: {
edit: {
title: 'Edit Subscription',
success: 'Subscription updated successfully',
error: 'Failed to update subscription',
},
delete: 'Delete',
deleteConfirm: {
title: 'Delete {{name}}?',

View File

@@ -211,6 +211,25 @@ export const useDeleteTriggerSubscription = () => {
})
}
export type UpdateTriggerSubscriptionPayload = {
subscriptionId: string
name?: string
properties?: Record<string, any>
}
export const useUpdateTriggerSubscription = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'update-subscription'],
mutationFn: (payload: UpdateTriggerSubscriptionPayload) => {
const { subscriptionId, ...body } = payload
return post<{ result: string; id: string }>(
`/workspaces/current/trigger-provider/${subscriptionId}/subscriptions/update`,
{ body },
)
},
})
}
export const useTriggerSubscriptionBuilderLogs = (
provider: string,
subscriptionBuilderId: string,