Compare commits

...

22 Commits

Author SHA1 Message Date
Harry
ef9a741781 feat(trigger): enhance trigger management with new error handling and response structure
- Added `TriggerInvokeError` and `TriggerIgnoreEventError` for better error categorization during trigger invocation.
- Updated `TriggerInvokeResponse` to include a `cancelled` field, indicating if a trigger was ignored.
- Enhanced `TriggerManager` to handle specific errors and return appropriate responses.
- Refactored `dispatch_triggered_workflows` to improve workflow execution logic and error handling.

These changes improve the robustness and clarity of the trigger management system.
2025-09-23 16:01:59 +08:00
Harry
c5de91ba94 refactor(trigger): update cache expiration constants and log key format
- Renamed validation-related constants to builder-related ones for clarity.
- Updated cache expiration from milliseconds to seconds for consistency.
- Adjusted log key format to reflect the builder context instead of validation.

These changes enhance the readability and maintainability of the TriggerSubscriptionBuilderService.
2025-09-22 13:37:46 +08:00
Harry
bc1e6e011b fix(trigger): update cache key format in TriggerSubscriptionBuilderService
- Changed the cache key format in the `encode_cache_key` method from `trigger:subscription:validation:{subscription_id}` to `trigger:subscription:builder:{subscription_id}` to better reflect its purpose.

This update improves clarity in cache key usage for trigger subscriptions.
2025-09-22 13:37:46 +08:00
lyzno1
906028b1fb fix: start node validation 2025-09-22 12:58:20 +08:00
lyzno1
034602969f feat(schedule-trigger): enhance cron parser with mature library and comprehensive testing (#26002) 2025-09-22 10:01:48 +08:00
非法操作
4ca14bfdad chore: improve webhook (#25998) 2025-09-21 12:16:31 +08:00
lyzno1
59f56d8c94 feat: schedule trigger default daily midnight (#25937) 2025-09-19 08:05:00 +08:00
yessenia
63d26f0478 fix: api key params 2025-09-18 17:35:34 +08:00
yessenia
eae65e55ce feat: oauth config opt & add dynamic options 2025-09-18 17:12:48 +08:00
lyzno1
0edf06329f fix: apply suggestions 2025-09-18 17:04:02 +08:00
lyzno1
6943a379c9 feat: show placeholder '--' for invalid cron expressions in node display
- Return '--' placeholder when cron mode has empty or invalid expressions
- Prevents displaying fallback dates that confuse users
- Maintains consistent UX for invalid schedule configurations
2025-09-18 17:04:02 +08:00
lyzno1
e49534b70c fix: make frequency optional 2025-09-18 17:04:02 +08:00
lyzno1
344616ca2f fix: clear opposite mode data only when editing, preserve data during mode switching 2025-09-18 17:04:02 +08:00
lyzno1
0e287a9c93 chore: add missing translations 2025-09-18 13:25:57 +08:00
lyzno1
8141f53af5 fix: add preventDefaultSubmit prop to BaseForm to prevent unwanted page refresh on Enter key 2025-09-18 12:48:26 +08:00
lyzno1
5a6cb0d887 feat: enhance API key modal step indicator with active dots and improved styling 2025-09-18 12:44:11 +08:00
lyzno1
26e7677595 fix: align width and use rounded xl 2025-09-18 12:08:21 +08:00
yessenia
814b0e1fe8 feat: oauth config init 2025-09-18 00:00:50 +08:00
Harry
a173dc5c9d feat(provider): add multiple option support in ProviderConfig
- Introduced a new field `multiple` in the `ProviderConfig` class to allow for multiple selections, enhancing the configuration capabilities for providers.
- This addition improves flexibility in provider settings and aligns with the evolving requirements for provider configurations.
2025-09-17 22:12:01 +08:00
Harry
a567facf2b refactor(trigger): streamline encrypter creation in TriggerProviderService
- Replaced calls to `create_trigger_provider_encrypter` and `create_trigger_provider_oauth_encrypter` with a unified `create_provider_encrypter` method, simplifying the encrypter creation process.
- Updated the parameters passed to the new method to enhance configuration management and cache handling.

These changes improve code clarity and maintainability in the trigger provider service.
2025-09-17 21:47:11 +08:00
Harry
e76d80defe fix(trigger): update client parameter handling in TriggerProviderService
- Modified the `create_provider_encrypter` call to include a cache assignment, ensuring proper management of encryption resources.
- Added a cache deletion step after updating client parameters, enhancing the integrity of the parameter handling process.

These changes improve the reliability of client parameter updates within the trigger provider service.
2025-09-17 20:57:52 +08:00
Harry
4a17025467 fix(trigger): update session management in TriggerProviderService
- Changed session management in `TriggerProviderService` from `autoflush=True` to `expire_on_commit=False` for improved control over session state.
- This change enhances the reliability of database interactions by preventing automatic expiration of objects after commit, ensuring data consistency during trigger operations.

These updates contribute to better session handling and stability in trigger-related functionalities.
2025-09-16 18:01:44 +08:00
62 changed files with 3412 additions and 761 deletions

View File

@@ -194,6 +194,7 @@ class ProviderConfig(BasicProviderConfig):
required: bool = False
default: Optional[Union[int, str, float, bool, list]] = None
options: Optional[list[Option]] = None
multiple: bool | None = False
label: Optional[I18nObject] = None
help: Optional[I18nObject] = None
url: Optional[str] = None

View File

@@ -247,6 +247,7 @@ class Event(BaseModel):
class TriggerInvokeResponse(BaseModel):
event: Event
cancelled: Optional[bool] = False
class PluginTriggerDispatchResponse(BaseModel):

View File

@@ -49,7 +49,7 @@ class TriggerProviderApiEntity(BaseModel):
supported_creation_methods: list[TriggerCreationMethod] = Field(
default_factory=list,
description="Supported creation methods for the trigger provider. Possible values: 'OAUTH', 'APIKEY', 'MANUAL'."
description="Supported creation methods for the trigger provider. like 'OAUTH', 'APIKEY', 'MANUAL'.",
)
credentials_schema: list[ProviderConfig] = Field(description="The credentials schema of the trigger provider")

View File

@@ -269,11 +269,6 @@ class TriggerInputs(BaseModel):
trigger_name: str
subscription_id: str
@classmethod
def from_trigger_entity(cls, request_id: str, subscription_id: str, trigger: TriggerEntity) -> "TriggerInputs":
"""Create from trigger entity (for production)."""
return cls(request_id=request_id, trigger_name=trigger.identity.name, subscription_id=subscription_id)
def to_workflow_args(self) -> dict[str, Any]:
"""Convert to workflow arguments format."""
return {"inputs": self.model_dump(), "files": []}
@@ -282,11 +277,13 @@ class TriggerInputs(BaseModel):
"""Convert to dict (alias for model_dump)."""
return self.model_dump()
class TriggerCreationMethod(StrEnum):
OAUTH = "OAUTH"
APIKEY = "APIKEY"
MANUAL = "MANUAL"
# Export all entities
__all__ = [
"OAuthSchema",

View File

@@ -1,2 +1,8 @@
class TriggerProviderCredentialValidationError(ValueError):
pass
class TriggerInvokeError(Exception):
pass
class TriggerIgnoreEventError(TriggerInvokeError):
pass

View File

@@ -12,7 +12,8 @@ from flask import Request
import contexts
from core.plugin.entities.plugin import TriggerProviderID
from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.entities.request import TriggerInvokeResponse
from core.plugin.entities.request import Event, TriggerInvokeResponse
from core.plugin.impl.exc import PluginInvokeError
from core.plugin.impl.trigger import PluginTriggerManager
from core.trigger.entities.entities import (
Subscription,
@@ -168,7 +169,14 @@ class TriggerManager:
trigger = provider.get_trigger(trigger_name)
if not trigger:
raise ValueError(f"Trigger {trigger_name} not found in provider {provider_id}")
return provider.invoke_trigger(user_id, trigger_name, parameters, credentials, credential_type, request)
try:
return provider.invoke_trigger(user_id, trigger_name, parameters, credentials, credential_type, request)
except PluginInvokeError as e:
if e.get_error_type() == "TriggerIgnoreEventError":
return TriggerInvokeResponse(event=Event(variables={}), cancelled=True)
else:
logger.exception("Failed to invoke trigger")
raise
@classmethod
def subscribe_trigger(

View File

@@ -135,12 +135,12 @@ class Graph(BaseModel):
# fetch root node
if not root_node_id:
# if no root node id, use the START type node as root node
# if no root node id, use any start node (START or trigger types) as root node
root_node_id = next(
(
node_config.get("id")
for node_config in root_node_configs
if node_config.get("data", {}).get("type", "") == NodeType.START.value
if NodeType(node_config.get("data", {}).get("type", "")).is_start_node
),
None,
)

View File

@@ -1,18 +1,11 @@
from collections.abc import Mapping
from typing import Any, Optional
from core.plugin.entities.plugin import TriggerProviderID
from core.plugin.impl.exc import PluginDaemonClientSideError, PluginInvokeError
from core.plugin.utils.http_parser import deserialize_request
from core.trigger.entities.api_entities import TriggerProviderSubscriptionApiEntity
from core.trigger.trigger_manager import TriggerManager
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.enums import ErrorStrategy, NodeType
from extensions.ext_storage import storage
from services.trigger.trigger_provider_service import TriggerProviderService
from .entities import PluginTriggerData
@@ -78,74 +71,9 @@ class TriggerPluginNode(BaseNode):
"plugin_unique_identifier": self._node_data.plugin_unique_identifier,
},
}
request_id = trigger_inputs.get("request_id")
trigger_name = trigger_inputs.get("trigger_name", "")
subscription_id = trigger_inputs.get("subscription_id", "")
if not request_id or not subscription_id:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs=trigger_inputs,
outputs={"error": "No request ID or subscription ID available"},
)
try:
subscription: TriggerProviderSubscriptionApiEntity | None = TriggerProviderService.get_subscription_by_id(
tenant_id=self.tenant_id, subscription_id=subscription_id
)
if not subscription:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs=trigger_inputs,
outputs={"error": f"Invalid subscription {subscription_id} not found"},
)
except Exception as e:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs=trigger_inputs,
outputs={"error": f"Failed to get subscription: {str(e)}"},
)
try:
request = deserialize_request(storage.load_once(f"triggers/{request_id}"))
parameters = self._node_data.parameters if hasattr(self, "_node_data") and self._node_data else {}
invoke_response = TriggerManager.invoke_trigger(
tenant_id=self.tenant_id,
user_id=self.user_id,
provider_id=TriggerProviderID(subscription.provider),
trigger_name=trigger_name,
parameters=parameters,
credentials=subscription.credentials,
credential_type=subscription.credential_type,
request=request,
)
outputs = invoke_response.event.variables or {}
return NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=trigger_inputs, outputs=outputs)
except PluginInvokeError as e:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs=trigger_inputs,
metadata=metadata,
error="An error occurred in the plugin, "
f"please contact the author of {subscription.provider} for help, "
f"error type: {e.get_error_type()}, "
f"error details: {e.get_error_message()}",
error_type=type(e).__name__,
)
except PluginDaemonClientSideError as e:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs=trigger_inputs,
metadata=metadata,
error=f"Failed to invoke trigger, error: {e.description}",
error_type=type(e).__name__,
)
except Exception as e:
return NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
inputs=trigger_inputs,
metadata=metadata,
error=f"Failed to invoke trigger: {str(e)}",
error_type=type(e).__name__,
)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=trigger_inputs,
outputs=trigger_inputs,
metadata=metadata,
)

View File

@@ -38,7 +38,7 @@ class VisualConfig(BaseModel):
on_minute: Optional[int] = Field(default=0, ge=0, le=59, description="Minute of the hour (0-59)")
# For daily, weekly, monthly frequencies
time: Optional[str] = Field(default="12:00 PM", description="Time in 12-hour format (e.g., '2:30 PM')")
time: Optional[str] = Field(default="12:00 AM", description="Time in 12-hour format (e.g., '2:30 PM')")
# For weekly frequency
weekdays: Optional[list[Literal["sun", "mon", "tue", "wed", "thu", "fri", "sat"]]] = Field(

View File

@@ -46,8 +46,8 @@ class TriggerScheduleNode(BaseNode):
"type": "trigger-schedule",
"config": {
"mode": "visual",
"frequency": "weekly",
"visual_config": {"time": "11:30 AM", "on_minute": 0, "weekdays": ["sun"], "monthly_days": [1]},
"frequency": "daily",
"visual_config": {"time": "12:00 AM", "on_minute": 0, "weekdays": ["sun"], "monthly_days": [1]},
"timezone": "UTC",
},
}

View File

@@ -14,7 +14,7 @@ def calculate_next_run_at(
Calculate the next run time for a cron expression in a specific timezone.
Args:
cron_expression: Cron expression string (supports croniter extensions like 'L')
cron_expression: Standard 5-field cron expression or predefined expression
timezone: Timezone string (e.g., 'UTC', 'America/New_York')
base_time: Base time to calculate from (defaults to current UTC time)
@@ -22,10 +22,22 @@ def calculate_next_run_at(
Next run time in UTC
Note:
Supports croniter's extended syntax including:
- 'L' for last day of month
- Standard 5-field cron expressions
Supports enhanced cron syntax including:
- Month abbreviations: JAN, FEB, MAR-JUN, JAN,JUN,DEC
- Day abbreviations: MON, TUE, MON-FRI, SUN,WED,FRI
- Predefined expressions: @daily, @weekly, @monthly, @yearly, @hourly
- Special characters: ? wildcard, L (last day), Sunday as 7
- Standard 5-field format only (minute hour day month dayOfWeek)
"""
# Validate cron expression format to match frontend behavior
parts = cron_expression.strip().split()
# Support both 5-field format and predefined expressions (matching frontend)
if len(parts) != 5 and not cron_expression.startswith("@"):
raise ValueError(
f"Cron expression must have exactly 5 fields or be a predefined expression "
f"(@daily, @weekly, etc.). Got {len(parts)} fields: '{cron_expression}'"
)
tz = pytz.timezone(timezone)

View File

@@ -599,6 +599,10 @@ class AppDslService:
if data_type == NodeType.TRIGGER_SCHEDULE.value:
# override the config with the default config
node_data["config"] = TriggerScheduleNode.get_default_config()["config"]
if data_type == NodeType.TRIGGER_WEBHOOK.value:
# clear the webhook_url
node_data["webhook_url"] = ""
node_data["webhook_debug_url"] = ""
export_data["workflow"] = workflow_dict
dependencies = cls._extract_dependencies_from_workflow(workflow)

View File

@@ -23,7 +23,6 @@ from core.trigger.trigger_manager import TriggerManager
from core.trigger.utils.encryption import (
create_trigger_provider_encrypter_for_properties,
create_trigger_provider_encrypter_for_subscription,
create_trigger_provider_oauth_encrypter,
delete_cache_for_subscription,
)
from extensions.ext_database import db
@@ -60,7 +59,7 @@ class TriggerProviderService:
"""List all trigger subscriptions for the current tenant"""
subscriptions: list[TriggerProviderSubscriptionApiEntity] = []
workflows_in_use_map: dict[str, int] = {}
with Session(db.engine, autoflush=False) as session:
with Session(db.engine, expire_on_commit=False) as session:
# Get all subscriptions
subscriptions_db = (
session.query(TriggerSubscription)
@@ -128,7 +127,7 @@ class TriggerProviderService:
"""
try:
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
with Session(db.engine, autoflush=False) as session:
with Session(db.engine, expire_on_commit=False) as session:
# Use distributed lock to prevent race conditions
lock_key = f"trigger_provider_create_lock:{tenant_id}_{provider_id}"
with redis_client.lock(lock_key, timeout=20):
@@ -266,10 +265,10 @@ class TriggerProviderService:
provider_id = TriggerProviderID(db_provider.provider_id)
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
# Create encrypter
encrypter, cache = create_trigger_provider_encrypter_for_subscription(
encrypter, cache = create_provider_encrypter(
tenant_id=tenant_id,
controller=provider_controller,
subscription=db_provider,
config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()],
cache=NoOpProviderCredentialCache(),
)
# Decrypt current credentials
@@ -317,7 +316,7 @@ class TriggerProviderService:
:return: OAuth client configuration or None
"""
provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id)
with Session(db.engine, autoflush=False) as session:
with Session(db.engine, expire_on_commit=False) as session:
tenant_client: TriggerOAuthTenantClient | None = (
session.query(TriggerOAuthTenantClient)
.filter_by(
@@ -331,7 +330,11 @@ class TriggerProviderService:
oauth_params: Mapping[str, Any] | None = None
if tenant_client:
encrypter, _ = create_trigger_provider_oauth_encrypter(tenant_id, provider_controller)
encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()],
cache=NoOpProviderCredentialCache(),
)
oauth_params = encrypter.decrypt(tenant_client.oauth_params)
return oauth_params
@@ -400,7 +403,7 @@ class TriggerProviderService:
# Update client params if provided
if client_params is not None:
encrypter, _ = create_provider_encrypter(
encrypter, cache = create_provider_encrypter(
tenant_id=tenant_id,
config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()],
cache=NoOpProviderCredentialCache(),
@@ -413,6 +416,7 @@ class TriggerProviderService:
for key, value in client_params.items()
}
custom_client.encrypted_oauth_params = json.dumps(encrypter.encrypt(new_params))
cache.delete()
# Update enabled status if provided
if enabled is not None:
@@ -485,7 +489,7 @@ class TriggerProviderService:
:param provider_id: Provider identifier
:return: True if enabled, False otherwise
"""
with Session(db.engine, autoflush=False) as session:
with Session(db.engine, expire_on_commit=False) as session:
custom_client = (
session.query(TriggerOAuthTenantClient)
.filter_by(
@@ -503,7 +507,7 @@ class TriggerProviderService:
"""
Get a trigger subscription by the endpoint ID.
"""
with Session(db.engine, autoflush=False) as session:
with Session(db.engine, expire_on_commit=False) as session:
subscription = session.query(TriggerSubscription).filter_by(endpoint_id=endpoint_id).first()
if not subscription:
return None

View File

@@ -35,14 +35,16 @@ class TriggerSubscriptionBuilderService:
__MAX_TRIGGER_PROVIDER_COUNT__ = 10
##########################
# Validation endpoint
# Builder endpoint
##########################
__BUILDER_CACHE_EXPIRE_SECONDS__ = 30 * 60
__VALIDATION_REQUEST_CACHE_COUNT__ = 10
__VALIDATION_REQUEST_CACHE_EXPIRE_MS__ = 30 * 60 * 1000
__VALIDATION_REQUEST_CACHE_EXPIRE_SECONDS__ = 30 * 60
@classmethod
def encode_cache_key(cls, subscription_id: str) -> str:
return f"trigger:subscription:validation:{subscription_id}"
return f"trigger:subscription:builder:{subscription_id}"
@classmethod
def verify_trigger_subscription_builder(
@@ -167,9 +169,7 @@ class TriggerSubscriptionBuilderService:
expires_at=-1,
)
cache_key = cls.encode_cache_key(subscription_id)
redis_client.setex(
cache_key, cls.__VALIDATION_REQUEST_CACHE_EXPIRE_MS__, subscription_builder.model_dump_json()
)
redis_client.setex(cache_key, cls.__BUILDER_CACHE_EXPIRE_SECONDS__, subscription_builder.model_dump_json())
return cls.builder_to_api_entity(controller=provider_controller, entity=subscription_builder)
@classmethod
@@ -196,7 +196,7 @@ class TriggerSubscriptionBuilderService:
subscription_builder_updater.update(subscription_builder_cache)
redis_client.setex(
cache_key, cls.__VALIDATION_REQUEST_CACHE_EXPIRE_MS__, subscription_builder_cache.model_dump_json()
cache_key, cls.__BUILDER_CACHE_EXPIRE_SECONDS__, subscription_builder_cache.model_dump_json()
)
return cls.builder_to_api_entity(controller=provider_controller, entity=subscription_builder_cache)
@@ -259,18 +259,18 @@ class TriggerSubscriptionBuilderService:
created_at=datetime.now(),
)
key = f"trigger:subscription:validation:logs:{endpoint_id}"
key = f"trigger:subscription:builder:logs:{endpoint_id}"
logs = json.loads(redis_client.get(key) or "[]")
logs.append(log.model_dump(mode="json"))
# Keep last N logs
logs = logs[-cls.__VALIDATION_REQUEST_CACHE_COUNT__ :]
redis_client.setex(key, cls.__VALIDATION_REQUEST_CACHE_EXPIRE_MS__, json.dumps(logs, default=str))
redis_client.setex(key, cls.__VALIDATION_REQUEST_CACHE_EXPIRE_SECONDS__, json.dumps(logs, default=str))
@classmethod
def list_logs(cls, endpoint_id: str) -> list[RequestLog]:
"""List request logs for validation endpoint."""
key = f"trigger:subscription:validation:logs:{endpoint_id}"
key = f"trigger:subscription:builder:logs:{endpoint_id}"
logs_json = redis_client.get(key)
if not logs_json:
return []

View File

@@ -1,15 +1,19 @@
import logging
import time
import uuid
from collections.abc import Mapping, Sequence
from flask import Request, Response
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from core.plugin.entities.plugin import TriggerProviderID
from core.plugin.utils.http_parser import serialize_request
from core.trigger.entities.entities import TriggerEntity, TriggerInputs
from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.utils.http_parser import deserialize_request, serialize_request
from core.trigger.entities.entities import TriggerEntity
from core.trigger.trigger_manager import TriggerManager
from core.workflow.nodes.enums import NodeType
from core.workflow.nodes.trigger_schedule.exc import TenantOwnerNotFoundError
from extensions.ext_database import db
from extensions.ext_storage import storage
from models.account import Account, TenantAccountJoin, TenantAccountRole
@@ -30,6 +34,40 @@ class TriggerService:
__WEBHOOK_NODE_CACHE_KEY__ = "webhook_nodes"
@classmethod
def _get_latest_workflows_by_app_ids(
cls, session: Session, subscribers: Sequence[WorkflowPluginTrigger]
) -> Mapping[str, Workflow]:
"""Get the latest workflows by app_ids"""
workflow_query = (
select(Workflow.app_id, func.max(Workflow.created_at).label("max_created_at"))
.where(
Workflow.app_id.in_({t.app_id for t in subscribers}),
Workflow.version != Workflow.VERSION_DRAFT,
)
.group_by(Workflow.app_id)
.subquery()
)
workflows = session.scalars(
select(Workflow).join(
workflow_query,
(Workflow.app_id == workflow_query.c.app_id) & (Workflow.created_at == workflow_query.c.max_created_at),
)
).all()
return {w.app_id: w for w in workflows}
@classmethod
def _get_tenant_owner(cls, session: Session, tenant_id: str) -> Account:
"""Get the tenant owner account for workflow execution."""
owner = session.scalar(
select(Account)
.join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == TenantAccountRole.OWNER)
)
if not owner:
raise TenantOwnerNotFoundError(f"Tenant owner not found for tenant {tenant_id}")
return owner
@classmethod
def dispatch_triggered_workflows(
cls, subscription: TriggerSubscription, trigger: TriggerEntity, request_id: str
@@ -41,8 +79,12 @@ class TriggerService:
trigger: The trigger entity that was activated
request_id: The ID of the stored request in storage system
"""
request = deserialize_request(storage.load_once(f"triggers/{request_id}"))
if not request:
logger.error("Request not found for request_id %s", request_id)
return 0
subscribers = cls.get_subscriber_triggers(
subscribers: list[WorkflowPluginTrigger] = cls.get_subscriber_triggers(
tenant_id=subscription.tenant_id, subscription_id=subscription.id, trigger_name=trigger.identity.name
)
if not subscribers:
@@ -53,32 +95,13 @@ class TriggerService:
)
return 0
dispatched_count = 0
with Session(db.engine) as session:
# Get tenant owner for workflow execution
tenant_owner = session.scalar(
select(Account)
.join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
.where(
TenantAccountJoin.tenant_id == subscription.tenant_id,
TenantAccountJoin.role == TenantAccountRole.OWNER,
)
)
if not tenant_owner:
logger.error("Tenant owner not found for tenant %s", subscription.tenant_id)
return 0
dispatched_count = 0
tenant_owner = cls._get_tenant_owner(session, subscription.tenant_id)
workflows = cls._get_latest_workflows_by_app_ids(session, subscribers)
for plugin_trigger in subscribers:
# Get workflow
workflow = session.scalar(
select(Workflow)
.where(
Workflow.app_id == plugin_trigger.app_id,
Workflow.version != Workflow.VERSION_DRAFT,
)
.order_by(Workflow.created_at.desc())
)
# Get workflow from mapping
workflow = workflows.get(plugin_trigger.app_id)
if not workflow:
logger.error(
"Workflow not found for app %s",
@@ -86,10 +109,35 @@ class TriggerService:
)
continue
# Create trigger inputs using new structure
trigger_inputs = TriggerInputs.from_trigger_entity(
request_id=request_id, subscription_id=subscription.id, trigger=trigger
# Find the trigger node in the workflow
trigger_node = None
for node_id, node_config in workflow.walk_nodes(NodeType.TRIGGER_PLUGIN):
if node_id == plugin_trigger.node_id:
trigger_node = node_config
break
if not trigger_node:
logger.error("Trigger node not found for app %s", plugin_trigger.app_id)
continue
# invoke triger
invoke_response = TriggerManager.invoke_trigger(
tenant_id=subscription.tenant_id,
user_id=subscription.user_id,
provider_id=TriggerProviderID(subscription.provider_id),
trigger_name=trigger.identity.name,
parameters=trigger_node.get("config", {}),
credentials=subscription.credentials,
credential_type=CredentialType.of(subscription.credential_type),
request=request,
)
if invoke_response.cancelled:
logger.info(
"Trigger ignored for app %s with trigger %s",
plugin_trigger.app_id,
trigger.identity.name,
)
continue
# Create trigger data for async execution
trigger_data = PluginTriggerData(
@@ -100,7 +148,7 @@ class TriggerService:
trigger_type=WorkflowRunTriggeredFrom.PLUGIN,
plugin_id=subscription.provider_id,
endpoint_id=subscription.endpoint_id,
inputs=trigger_inputs.to_dict(),
inputs=invoke_response.event.variables,
)
# Trigger async workflow
@@ -150,6 +198,7 @@ class TriggerService:
# Production dispatch
from tasks.trigger_processing_tasks import dispatch_triggered_workflows_async
plugin_trigger_dispatch_data = PluginTriggerDispatchData(
endpoint_id=endpoint_id,
provider_id=subscription.provider_id,

View File

@@ -669,6 +669,7 @@ class WebhookService:
created_by=app.created_by,
)
session.add(webhook_record)
session.flush()
cache = Cache(record_id=webhook_record.id, node_id=node_id, webhook_id=webhook_record.webhook_id)
redis_client.set(f"{cls.__WEBHOOK_NODE_CACHE_KEY__}:{node_id}", cache.model_dump_json(), ex=60 * 60)
session.commit()

View File

@@ -57,6 +57,7 @@ class PluginTriggerData(TriggerData):
plugin_id: str
endpoint_id: str
class PluginTriggerDispatchData(BaseModel):
"""Plugin trigger dispatch data for Celery tasks"""
@@ -67,6 +68,7 @@ class PluginTriggerDispatchData(BaseModel):
triggers: list[str]
request_id: str
class WorkflowTaskData(BaseModel):
"""Lightweight data structure for Celery workflow tasks"""

View File

@@ -43,9 +43,7 @@ def dispatch_triggered_workflows_async(
Returns:
dict: Execution result with status and dispatched trigger count
"""
dispatch_params: PluginTriggerDispatchData = PluginTriggerDispatchData.model_validate(
dispatch_data
)
dispatch_params: PluginTriggerDispatchData = PluginTriggerDispatchData.model_validate(dispatch_data)
endpoint_id = dispatch_params.endpoint_id
provider_id = dispatch_params.provider_id
subscription_id = dispatch_params.subscription_id

View File

@@ -0,0 +1,381 @@
"""
Enhanced cron syntax compatibility tests for croniter backend.
This test suite mirrors the frontend cron-parser tests to ensure
complete compatibility between frontend and backend cron processing.
"""
import unittest
from datetime import UTC, datetime, timedelta
import pytest
import pytz
from croniter import CroniterBadCronError
from libs.schedule_utils import calculate_next_run_at
class TestCronCompatibility(unittest.TestCase):
"""Test enhanced cron syntax compatibility with frontend."""
def setUp(self):
"""Set up test environment with fixed time."""
self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)
def test_enhanced_dayofweek_syntax(self):
"""Test enhanced day-of-week syntax compatibility."""
test_cases = [
("0 9 * * 7", 0), # Sunday as 7
("0 9 * * 0", 0), # Sunday as 0
("0 9 * * MON", 1), # Monday abbreviation
("0 9 * * TUE", 2), # Tuesday abbreviation
("0 9 * * WED", 3), # Wednesday abbreviation
("0 9 * * THU", 4), # Thursday abbreviation
("0 9 * * FRI", 5), # Friday abbreviation
("0 9 * * SAT", 6), # Saturday abbreviation
("0 9 * * SUN", 0), # Sunday abbreviation
]
for expr, expected_weekday in test_cases:
with self.subTest(expr=expr):
next_time = calculate_next_run_at(expr, "UTC", self.base_time)
assert next_time is not None
assert (next_time.weekday() + 1 if next_time.weekday() < 6 else 0) == expected_weekday
assert next_time.hour == 9
assert next_time.minute == 0
def test_enhanced_month_syntax(self):
"""Test enhanced month syntax compatibility."""
test_cases = [
("0 9 1 JAN *", 1), # January abbreviation
("0 9 1 FEB *", 2), # February abbreviation
("0 9 1 MAR *", 3), # March abbreviation
("0 9 1 APR *", 4), # April abbreviation
("0 9 1 MAY *", 5), # May abbreviation
("0 9 1 JUN *", 6), # June abbreviation
("0 9 1 JUL *", 7), # July abbreviation
("0 9 1 AUG *", 8), # August abbreviation
("0 9 1 SEP *", 9), # September abbreviation
("0 9 1 OCT *", 10), # October abbreviation
("0 9 1 NOV *", 11), # November abbreviation
("0 9 1 DEC *", 12), # December abbreviation
]
for expr, expected_month in test_cases:
with self.subTest(expr=expr):
next_time = calculate_next_run_at(expr, "UTC", self.base_time)
assert next_time is not None
assert next_time.month == expected_month
assert next_time.day == 1
assert next_time.hour == 9
def test_predefined_expressions(self):
"""Test predefined cron expressions compatibility."""
test_cases = [
("@yearly", lambda dt: dt.month == 1 and dt.day == 1 and dt.hour == 0),
("@annually", lambda dt: dt.month == 1 and dt.day == 1 and dt.hour == 0),
("@monthly", lambda dt: dt.day == 1 and dt.hour == 0),
("@weekly", lambda dt: dt.weekday() == 6 and dt.hour == 0), # Sunday = 6 in weekday()
("@daily", lambda dt: dt.hour == 0 and dt.minute == 0),
("@midnight", lambda dt: dt.hour == 0 and dt.minute == 0),
("@hourly", lambda dt: dt.minute == 0),
]
for expr, validator in test_cases:
with self.subTest(expr=expr):
next_time = calculate_next_run_at(expr, "UTC", self.base_time)
assert next_time is not None
assert validator(next_time), f"Validator failed for {expr}: {next_time}"
def test_special_characters(self):
"""Test special characters in cron expressions."""
test_cases = [
"0 9 ? * 1", # ? wildcard
"0 12 * * 7", # Sunday as 7
"0 15 L * *", # Last day of month
]
for expr in test_cases:
with self.subTest(expr=expr):
try:
next_time = calculate_next_run_at(expr, "UTC", self.base_time)
assert next_time is not None
assert next_time > self.base_time
except Exception as e:
self.fail(f"Expression '{expr}' should be valid but raised: {e}")
def test_range_and_list_syntax(self):
"""Test range and list syntax with abbreviations."""
test_cases = [
"0 9 * * MON-FRI", # Weekday range with abbreviations
"0 9 * JAN-MAR *", # Month range with abbreviations
"0 9 * * SUN,WED,FRI", # Weekday list with abbreviations
"0 9 1 JAN,JUN,DEC *", # Month list with abbreviations
]
for expr in test_cases:
with self.subTest(expr=expr):
try:
next_time = calculate_next_run_at(expr, "UTC", self.base_time)
assert next_time is not None
assert next_time > self.base_time
except Exception as e:
self.fail(f"Expression '{expr}' should be valid but raised: {e}")
def test_invalid_enhanced_syntax(self):
"""Test that invalid enhanced syntax is properly rejected."""
invalid_expressions = [
"0 12 * JANUARY *", # Full month name (not supported)
"0 12 * * MONDAY", # Full day name (not supported)
"0 12 32 JAN *", # Invalid day with valid month
"15 10 1 * 8", # Invalid day of week
"15 10 1 INVALID *", # Invalid month abbreviation
"15 10 1 * INVALID", # Invalid day abbreviation
"@invalid", # Invalid predefined expression
]
for expr in invalid_expressions:
with self.subTest(expr=expr):
with pytest.raises((CroniterBadCronError, ValueError)):
calculate_next_run_at(expr, "UTC", self.base_time)
def test_edge_cases_with_enhanced_syntax(self):
"""Test edge cases with enhanced syntax."""
test_cases = [
("0 0 29 FEB *", lambda dt: dt.month == 2 and dt.day == 29), # Feb 29 with month abbreviation
]
for expr, validator in test_cases:
with self.subTest(expr=expr):
try:
next_time = calculate_next_run_at(expr, "UTC", self.base_time)
if next_time: # Some combinations might not occur soon
assert validator(next_time), f"Validator failed for {expr}: {next_time}"
except (CroniterBadCronError, ValueError):
# Some edge cases might be valid but not have upcoming occurrences
pass
# Test complex expressions that have specific constraints
complex_expr = "59 23 31 DEC SAT" # December 31st at 23:59 on Saturday
try:
next_time = calculate_next_run_at(complex_expr, "UTC", self.base_time)
if next_time:
# The next occurrence might not be exactly Dec 31 if it's not a Saturday
# Just verify it's a valid result
assert next_time is not None
assert next_time.hour == 23
assert next_time.minute == 59
except Exception:
# Complex date constraints might not have near-future occurrences
pass
class TestTimezoneCompatibility(unittest.TestCase):
"""Test timezone compatibility between frontend and backend."""
def setUp(self):
"""Set up test environment."""
self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)
def test_timezone_consistency(self):
"""Test that calculations are consistent across different timezones."""
timezones = [
"UTC",
"America/New_York",
"Europe/London",
"Asia/Tokyo",
"Asia/Kolkata",
"Australia/Sydney",
]
expression = "0 12 * * *" # Daily at noon
for timezone in timezones:
with self.subTest(timezone=timezone):
next_time = calculate_next_run_at(expression, timezone, self.base_time)
assert next_time is not None
# Convert back to the target timezone to verify it's noon
tz = pytz.timezone(timezone)
local_time = next_time.astimezone(tz)
assert local_time.hour == 12
assert local_time.minute == 0
def test_dst_handling(self):
"""Test DST boundary handling."""
# Test around DST spring forward (March 2024)
dst_base = datetime(2024, 3, 8, 10, 0, 0, tzinfo=UTC)
expression = "0 2 * * *" # 2 AM daily (problematic during DST)
timezone = "America/New_York"
try:
next_time = calculate_next_run_at(expression, timezone, dst_base)
assert next_time is not None
# During DST spring forward, 2 AM becomes 3 AM - both are acceptable
tz = pytz.timezone(timezone)
local_time = next_time.astimezone(tz)
assert local_time.hour in [2, 3] # Either 2 AM or 3 AM is acceptable
except Exception as e:
self.fail(f"DST handling failed: {e}")
def test_half_hour_timezones(self):
"""Test timezones with half-hour offsets."""
timezones_with_offsets = [
("Asia/Kolkata", 17, 30), # UTC+5:30 -> 12:00 UTC = 17:30 IST
("Australia/Adelaide", 22, 30), # UTC+10:30 -> 12:00 UTC = 22:30 ACDT (summer time)
]
expression = "0 12 * * *" # Noon UTC
for timezone, expected_hour, expected_minute in timezones_with_offsets:
with self.subTest(timezone=timezone):
try:
next_time = calculate_next_run_at(expression, timezone, self.base_time)
assert next_time is not None
tz = pytz.timezone(timezone)
local_time = next_time.astimezone(tz)
assert local_time.hour == expected_hour
assert local_time.minute == expected_minute
except Exception:
# Some complex timezone calculations might vary
pass
def test_invalid_timezone_handling(self):
"""Test handling of invalid timezones."""
expression = "0 12 * * *"
invalid_timezone = "Invalid/Timezone"
with pytest.raises((ValueError, Exception)): # Should raise an exception
calculate_next_run_at(expression, invalid_timezone, self.base_time)
class TestFrontendBackendIntegration(unittest.TestCase):
"""Test integration patterns that mirror frontend usage."""
def setUp(self):
"""Set up test environment."""
self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)
def test_execution_time_calculator_pattern(self):
"""Test the pattern used by execution-time-calculator.ts."""
# This mirrors the exact usage from execution-time-calculator.ts:47
test_data = {
"cron_expression": "30 14 * * 1-5", # 2:30 PM weekdays
"timezone": "America/New_York",
}
# Get next 5 execution times (like the frontend does)
execution_times = []
current_base = self.base_time
for _ in range(5):
next_time = calculate_next_run_at(test_data["cron_expression"], test_data["timezone"], current_base)
assert next_time is not None
execution_times.append(next_time)
current_base = next_time + timedelta(seconds=1) # Move slightly forward
assert len(execution_times) == 5
# Validate each execution time
for exec_time in execution_times:
# Convert to local timezone
tz = pytz.timezone(test_data["timezone"])
local_time = exec_time.astimezone(tz)
# Should be weekdays (1-5)
assert local_time.weekday() in [0, 1, 2, 3, 4] # Mon-Fri in Python weekday
# Should be 2:30 PM in local time
assert local_time.hour == 14
assert local_time.minute == 30
assert local_time.second == 0
def test_schedule_service_integration(self):
"""Test integration with ScheduleService patterns."""
from core.workflow.nodes.trigger_schedule.entities import VisualConfig
from services.schedule_service import ScheduleService
# Test enhanced syntax through visual config conversion
visual_configs = [
# Test with month abbreviations
{
"frequency": "monthly",
"config": VisualConfig(time="9:00 AM", monthly_days=[1]),
"expected_cron": "0 9 1 * *",
},
# Test with weekday abbreviations
{
"frequency": "weekly",
"config": VisualConfig(time="2:30 PM", weekdays=["mon", "wed", "fri"]),
"expected_cron": "30 14 * * 1,3,5",
},
]
for test_case in visual_configs:
with self.subTest(frequency=test_case["frequency"]):
cron_expr = ScheduleService.visual_to_cron(test_case["frequency"], test_case["config"])
assert cron_expr == test_case["expected_cron"]
# Verify the generated cron expression is valid
next_time = calculate_next_run_at(cron_expr, "UTC", self.base_time)
assert next_time is not None
def test_error_handling_consistency(self):
"""Test that error handling matches frontend expectations."""
invalid_expressions = [
"60 10 1 * *", # Invalid minute
"15 25 1 * *", # Invalid hour
"15 10 32 * *", # Invalid day
"15 10 1 13 *", # Invalid month
"15 10 1", # Too few fields
"15 10 1 * * *", # 6 fields (not supported in frontend)
"0 15 10 1 * * *", # 7 fields (not supported in frontend)
"invalid expression", # Completely invalid
]
for expr in invalid_expressions:
with self.subTest(expr=repr(expr)):
with pytest.raises((CroniterBadCronError, ValueError, Exception)):
calculate_next_run_at(expr, "UTC", self.base_time)
# Note: Empty/whitespace expressions are not tested here as they are
# not expected in normal usage due to database constraints (nullable=False)
def test_performance_requirements(self):
"""Test that complex expressions parse within reasonable time."""
import time
complex_expressions = [
"*/5 9-17 * * 1-5", # Every 5 minutes, weekdays, business hours
"0 */2 1,15 * *", # Every 2 hours on 1st and 15th
"30 14 * * 1,3,5", # Mon, Wed, Fri at 14:30
"15,45 8-18 * * 1-5", # 15 and 45 minutes past hour, weekdays
"0 9 * JAN-MAR MON-FRI", # Enhanced syntax: Q1 weekdays at 9 AM
"0 12 ? * SUN", # Enhanced syntax: Sundays at noon with ?
]
start_time = time.time()
for expr in complex_expressions:
with self.subTest(expr=expr):
try:
next_time = calculate_next_run_at(expr, "UTC", self.base_time)
assert next_time is not None
except CroniterBadCronError:
# Some enhanced syntax might not be supported, that's OK
pass
end_time = time.time()
execution_time = (end_time - start_time) * 1000 # Convert to milliseconds
# Should complete within reasonable time (less than 150ms like frontend)
assert execution_time < 150, "Complex expressions should parse quickly"
if __name__ == "__main__":
# Import timedelta for the test
from datetime import timedelta
unittest.main()

View File

@@ -0,0 +1,411 @@
"""
Enhanced schedule_utils tests for new cron syntax support.
These tests verify that the backend schedule_utils functions properly support
the enhanced cron syntax introduced in the frontend, ensuring full compatibility.
"""
import unittest
from datetime import UTC, datetime, timedelta
import pytest
import pytz
from croniter import CroniterBadCronError
from libs.schedule_utils import calculate_next_run_at, convert_12h_to_24h
class TestEnhancedCronSyntax(unittest.TestCase):
"""Test enhanced cron syntax in calculate_next_run_at."""
def setUp(self):
"""Set up test with fixed time."""
# Monday, January 15, 2024, 10:00 AM UTC
self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)
def test_month_abbreviations(self):
"""Test month abbreviations (JAN, FEB, etc.)."""
test_cases = [
("0 12 1 JAN *", 1), # January
("0 12 1 FEB *", 2), # February
("0 12 1 MAR *", 3), # March
("0 12 1 APR *", 4), # April
("0 12 1 MAY *", 5), # May
("0 12 1 JUN *", 6), # June
("0 12 1 JUL *", 7), # July
("0 12 1 AUG *", 8), # August
("0 12 1 SEP *", 9), # September
("0 12 1 OCT *", 10), # October
("0 12 1 NOV *", 11), # November
("0 12 1 DEC *", 12), # December
]
for expr, expected_month in test_cases:
with self.subTest(expr=expr):
result = calculate_next_run_at(expr, "UTC", self.base_time)
assert result is not None, f"Failed to parse: {expr}"
assert result.month == expected_month
assert result.day == 1
assert result.hour == 12
assert result.minute == 0
def test_weekday_abbreviations(self):
"""Test weekday abbreviations (SUN, MON, etc.)."""
test_cases = [
("0 9 * * SUN", 6), # Sunday (weekday() = 6)
("0 9 * * MON", 0), # Monday (weekday() = 0)
("0 9 * * TUE", 1), # Tuesday
("0 9 * * WED", 2), # Wednesday
("0 9 * * THU", 3), # Thursday
("0 9 * * FRI", 4), # Friday
("0 9 * * SAT", 5), # Saturday
]
for expr, expected_weekday in test_cases:
with self.subTest(expr=expr):
result = calculate_next_run_at(expr, "UTC", self.base_time)
assert result is not None, f"Failed to parse: {expr}"
assert result.weekday() == expected_weekday
assert result.hour == 9
assert result.minute == 0
def test_sunday_dual_representation(self):
"""Test Sunday as both 0 and 7."""
base_time = datetime(2024, 1, 14, 10, 0, 0, tzinfo=UTC) # Sunday
# Both should give the same next Sunday
result_0 = calculate_next_run_at("0 10 * * 0", "UTC", base_time)
result_7 = calculate_next_run_at("0 10 * * 7", "UTC", base_time)
result_SUN = calculate_next_run_at("0 10 * * SUN", "UTC", base_time)
assert result_0 is not None
assert result_7 is not None
assert result_SUN is not None
# All should be Sundays
assert result_0.weekday() == 6 # Sunday = 6 in weekday()
assert result_7.weekday() == 6
assert result_SUN.weekday() == 6
# Times should be identical
assert result_0 == result_7
assert result_0 == result_SUN
def test_predefined_expressions(self):
"""Test predefined expressions (@daily, @weekly, etc.)."""
test_cases = [
("@yearly", lambda dt: dt.month == 1 and dt.day == 1 and dt.hour == 0 and dt.minute == 0),
("@annually", lambda dt: dt.month == 1 and dt.day == 1 and dt.hour == 0 and dt.minute == 0),
("@monthly", lambda dt: dt.day == 1 and dt.hour == 0 and dt.minute == 0),
("@weekly", lambda dt: dt.weekday() == 6 and dt.hour == 0 and dt.minute == 0), # Sunday
("@daily", lambda dt: dt.hour == 0 and dt.minute == 0),
("@midnight", lambda dt: dt.hour == 0 and dt.minute == 0),
("@hourly", lambda dt: dt.minute == 0),
]
for expr, validator in test_cases:
with self.subTest(expr=expr):
result = calculate_next_run_at(expr, "UTC", self.base_time)
assert result is not None, f"Failed to parse: {expr}"
assert validator(result), f"Validator failed for {expr}: {result}"
def test_question_mark_wildcard(self):
"""Test ? wildcard character."""
# ? in day position with specific weekday
result_question = calculate_next_run_at("0 9 ? * 1", "UTC", self.base_time) # Monday
result_star = calculate_next_run_at("0 9 * * 1", "UTC", self.base_time) # Monday
assert result_question is not None
assert result_star is not None
# Both should return Mondays at 9:00
assert result_question.weekday() == 0 # Monday
assert result_star.weekday() == 0
assert result_question.hour == 9
assert result_star.hour == 9
# Results should be identical
assert result_question == result_star
def test_last_day_of_month(self):
"""Test 'L' for last day of month."""
expr = "0 12 L * *" # Last day of month at noon
# Test for February (28 days in 2024 - not a leap year check)
feb_base = datetime(2024, 2, 15, 10, 0, 0, tzinfo=UTC)
result = calculate_next_run_at(expr, "UTC", feb_base)
assert result is not None
assert result.month == 2
assert result.day == 29 # 2024 is a leap year
assert result.hour == 12
def test_range_with_abbreviations(self):
"""Test ranges using abbreviations."""
test_cases = [
"0 9 * * MON-FRI", # Weekday range
"0 12 * JAN-MAR *", # Q1 months
"0 15 * APR-JUN *", # Q2 months
]
for expr in test_cases:
with self.subTest(expr=expr):
result = calculate_next_run_at(expr, "UTC", self.base_time)
assert result is not None, f"Failed to parse range expression: {expr}"
assert result > self.base_time
def test_list_with_abbreviations(self):
"""Test lists using abbreviations."""
test_cases = [
("0 9 * * SUN,WED,FRI", [6, 2, 4]), # Specific weekdays
("0 12 1 JAN,JUN,DEC *", [1, 6, 12]), # Specific months
]
for expr, expected_values in test_cases:
with self.subTest(expr=expr):
result = calculate_next_run_at(expr, "UTC", self.base_time)
assert result is not None, f"Failed to parse list expression: {expr}"
if "* *" in expr: # Weekday test
assert result.weekday() in expected_values
else: # Month test
assert result.month in expected_values
def test_mixed_syntax(self):
"""Test mixed traditional and enhanced syntax."""
test_cases = [
"30 14 15 JAN,JUN,DEC *", # Numbers + month abbreviations
"0 9 * JAN-MAR MON-FRI", # Month range + weekday range
"45 8 1,15 * MON", # Numbers + weekday abbreviation
]
for expr in test_cases:
with self.subTest(expr=expr):
result = calculate_next_run_at(expr, "UTC", self.base_time)
assert result is not None, f"Failed to parse mixed syntax: {expr}"
assert result > self.base_time
def test_complex_enhanced_expressions(self):
"""Test complex expressions with multiple enhanced features."""
# Note: Some of these might not be supported by croniter, that's OK
complex_expressions = [
"0 9 L JAN *", # Last day of January
"30 14 * * FRI#1", # First Friday of month (if supported)
"0 12 15 JAN-DEC/3 *", # 15th of every 3rd month (quarterly)
]
for expr in complex_expressions:
with self.subTest(expr=expr):
try:
result = calculate_next_run_at(expr, "UTC", self.base_time)
if result: # If supported, should return valid result
assert result > self.base_time
except Exception:
# Some complex expressions might not be supported - that's acceptable
pass
class TestTimezoneHandlingEnhanced(unittest.TestCase):
"""Test timezone handling with enhanced syntax."""
def setUp(self):
"""Set up test with fixed time."""
self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)
def test_enhanced_syntax_with_timezones(self):
"""Test enhanced syntax works correctly across timezones."""
timezones = ["UTC", "America/New_York", "Asia/Tokyo", "Europe/London"]
expression = "0 12 * * MON" # Monday at noon
for timezone in timezones:
with self.subTest(timezone=timezone):
result = calculate_next_run_at(expression, timezone, self.base_time)
assert result is not None
# Convert to local timezone to verify it's Monday at noon
tz = pytz.timezone(timezone)
local_time = result.astimezone(tz)
assert local_time.weekday() == 0 # Monday
assert local_time.hour == 12
assert local_time.minute == 0
def test_predefined_expressions_with_timezones(self):
"""Test predefined expressions work with different timezones."""
expression = "@daily"
timezones = ["UTC", "America/New_York", "Asia/Tokyo"]
for timezone in timezones:
with self.subTest(timezone=timezone):
result = calculate_next_run_at(expression, timezone, self.base_time)
assert result is not None
# Should be midnight in the specified timezone
tz = pytz.timezone(timezone)
local_time = result.astimezone(tz)
assert local_time.hour == 0
assert local_time.minute == 0
def test_dst_with_enhanced_syntax(self):
"""Test DST handling with enhanced syntax."""
# DST spring forward date in 2024
dst_base = datetime(2024, 3, 8, 10, 0, 0, tzinfo=UTC)
expression = "0 2 * * SUN" # Sunday at 2 AM (problematic during DST)
timezone = "America/New_York"
result = calculate_next_run_at(expression, timezone, dst_base)
assert result is not None
# Should handle DST transition gracefully
tz = pytz.timezone(timezone)
local_time = result.astimezone(tz)
assert local_time.weekday() == 6 # Sunday
# During DST spring forward, 2 AM might become 3 AM
assert local_time.hour in [2, 3]
class TestErrorHandlingEnhanced(unittest.TestCase):
"""Test error handling for enhanced syntax."""
def setUp(self):
"""Set up test with fixed time."""
self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)
def test_invalid_enhanced_syntax(self):
"""Test that invalid enhanced syntax raises appropriate errors."""
invalid_expressions = [
"0 12 * JANUARY *", # Full month name
"0 12 * * MONDAY", # Full day name
"0 12 32 JAN *", # Invalid day with valid month
"0 12 * * MON-SUN-FRI", # Invalid range syntax
"0 12 * JAN- *", # Incomplete range
"0 12 * * ,MON", # Invalid list syntax
"@INVALID", # Invalid predefined
]
for expr in invalid_expressions:
with self.subTest(expr=expr):
with pytest.raises((CroniterBadCronError, ValueError)):
calculate_next_run_at(expr, "UTC", self.base_time)
def test_boundary_values_with_enhanced_syntax(self):
"""Test boundary values work with enhanced syntax."""
# Valid boundary expressions
valid_expressions = [
"0 0 1 JAN *", # Minimum: January 1st midnight
"59 23 31 DEC *", # Maximum: December 31st 23:59
"0 12 29 FEB *", # Leap year boundary
]
for expr in valid_expressions:
with self.subTest(expr=expr):
try:
result = calculate_next_run_at(expr, "UTC", self.base_time)
if result: # Some dates might not occur soon
assert result > self.base_time
except Exception as e:
# Some boundary cases might be complex to calculate
self.fail(f"Valid boundary expression failed: {expr} - {e}")
class TestPerformanceEnhanced(unittest.TestCase):
"""Test performance with enhanced syntax."""
def setUp(self):
"""Set up test with fixed time."""
self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)
def test_complex_expression_performance(self):
"""Test that complex enhanced expressions parse within reasonable time."""
import time
complex_expressions = [
"*/5 9-17 * * MON-FRI", # Every 5 min, weekdays, business hours
"0 9 * JAN-MAR MON-FRI", # Q1 weekdays at 9 AM
"30 14 1,15 * * ", # 1st and 15th at 14:30
"0 12 ? * SUN", # Sundays at noon with ?
"@daily", # Predefined expression
]
start_time = time.time()
for expr in complex_expressions:
with self.subTest(expr=expr):
try:
result = calculate_next_run_at(expr, "UTC", self.base_time)
assert result is not None
except Exception:
# Some expressions might not be supported - acceptable
pass
end_time = time.time()
execution_time = (end_time - start_time) * 1000 # milliseconds
# Should be fast (less than 100ms for all expressions)
assert execution_time < 100, "Enhanced expressions should parse quickly"
def test_multiple_calculations_performance(self):
"""Test performance when calculating multiple next times."""
import time
expression = "0 9 * * MON-FRI" # Weekdays at 9 AM
iterations = 20
start_time = time.time()
current_time = self.base_time
for _ in range(iterations):
result = calculate_next_run_at(expression, "UTC", current_time)
assert result is not None
current_time = result + timedelta(seconds=1) # Move forward slightly
end_time = time.time()
total_time = (end_time - start_time) * 1000 # milliseconds
avg_time = total_time / iterations
# Average should be very fast (less than 5ms per calculation)
assert avg_time < 5, f"Average calculation time too slow: {avg_time}ms"
class TestRegressionEnhanced(unittest.TestCase):
"""Regression tests to ensure enhanced syntax doesn't break existing functionality."""
def setUp(self):
"""Set up test with fixed time."""
self.base_time = datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)
def test_traditional_syntax_still_works(self):
"""Ensure traditional cron syntax continues to work."""
traditional_expressions = [
"15 10 1 * *", # Monthly 1st at 10:15
"0 0 * * 0", # Weekly Sunday midnight
"*/5 * * * *", # Every 5 minutes
"0 9-17 * * 1-5", # Business hours weekdays
"30 14 * * 1", # Monday 14:30
"0 0 1,15 * *", # 1st and 15th midnight
]
for expr in traditional_expressions:
with self.subTest(expr=expr):
result = calculate_next_run_at(expr, "UTC", self.base_time)
assert result is not None, f"Traditional expression failed: {expr}"
assert result > self.base_time
def test_convert_12h_to_24h_unchanged(self):
"""Ensure convert_12h_to_24h function is unchanged."""
test_cases = [
("12:00 AM", (0, 0)), # Midnight
("12:00 PM", (12, 0)), # Noon
("1:30 AM", (1, 30)), # Early morning
("11:45 PM", (23, 45)), # Late evening
("6:15 AM", (6, 15)), # Morning
("3:30 PM", (15, 30)), # Afternoon
]
for time_str, expected in test_cases:
with self.subTest(time_str=time_str):
result = convert_12h_to_24h(time_str)
assert result == expected, f"12h conversion failed: {time_str}"
if __name__ == "__main__":
unittest.main()

View File

@@ -1,19 +1,32 @@
import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Input from '@/app/components/base/input'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
import { PortalSelect } from '@/app/components/base/select'
import PureSelect from '@/app/components/base/select/pure'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useTriggerPluginDynamicOptions } from '@/service/use-triggers'
import cn from '@/utils/classnames'
import { RiExternalLinkLine } from '@remixicon/react'
import type { AnyFieldApi } from '@tanstack/react-form'
import { useStore } from '@tanstack/react-form'
import {
isValidElement,
memo,
useMemo,
} from 'react'
import { RiExternalLinkLine } from '@remixicon/react'
import type { AnyFieldApi } from '@tanstack/react-form'
import { useStore } from '@tanstack/react-form'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input'
import PureSelect from '@/app/components/base/select/pure'
import type { FormSchema } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import Radio from '@/app/components/base/radio'
import RadioE from '@/app/components/base/radio/ui'
const getInputType = (type: FormTypeEnum) => {
switch (type) {
case FormTypeEnum.secretInput:
return 'password'
case FormTypeEnum.textNumber:
return 'number'
default:
return 'text'
}
}
export type BaseFieldProps = {
fieldClassName?: string
@@ -24,6 +37,7 @@ export type BaseFieldProps = {
field: AnyFieldApi
disabled?: boolean
}
const BaseField = ({
fieldClassName,
labelClassName,
@@ -42,19 +56,20 @@ const BaseField = ({
labelClassName: formLabelClassName,
show_on = [],
disabled: formSchemaDisabled,
showRadioUI,
type: formItemType,
dynamicSelectParams,
} = formSchema
const disabled = propsDisabled || formSchemaDisabled
const memorizedLabel = useMemo(() => {
if (isValidElement(label))
return label
if (typeof label === 'string')
if (isValidElement(label) || typeof label === 'string')
return label
if (typeof label === 'object' && label !== null)
return renderI18nObject(label as Record<string, string>)
}, [label, renderI18nObject])
const memorizedPlaceholder = useMemo(() => {
if (typeof placeholder === 'string')
return placeholder
@@ -62,25 +77,36 @@ const BaseField = ({
if (typeof placeholder === 'object' && placeholder !== null)
return renderI18nObject(placeholder as Record<string, string>)
}, [placeholder, renderI18nObject])
const optionValues = useStore(field.form.store, (s) => {
const watchedVariables = useMemo(() => {
const variables = new Set<string>()
for (const option of options || []) {
for (const condition of option.show_on || [])
variables.add(condition.variable)
}
for (const condition of show_on || [])
variables.add(condition.variable)
return Array.from(variables)
}, [options, show_on])
const watchedValues = useStore(field.form.store, (s) => {
const result: Record<string, any> = {}
options?.forEach((option) => {
if (option.show_on?.length) {
option.show_on.forEach((condition) => {
result[condition.variable] = s.values[condition.variable]
})
}
})
for (const variable of watchedVariables)
result[variable] = s.values[variable]
return result
})
const memorizedOptions = useMemo(() => {
return options?.filter((option) => {
if (!option.show_on || option.show_on.length === 0)
if (!option.show_on?.length)
return true
return option.show_on.every((condition) => {
const conditionValue = optionValues[condition.variable]
return conditionValue === condition.value
return watchedValues[condition.variable] === condition.value
})
}).map((option) => {
return {
@@ -88,20 +114,35 @@ const BaseField = ({
value: option.value,
}
}) || []
}, [options, renderI18nObject, optionValues])
}, [options, renderI18nObject, watchedValues])
const value = useStore(field.form.store, s => s.values[field.name])
const values = useStore(field.form.store, (s) => {
return show_on.reduce((acc, condition) => {
acc[condition.variable] = s.values[condition.variable]
return acc
}, {} as Record<string, any>)
})
const { data: dynamicOptionsData, isLoading: isDynamicOptionsLoading } = useTriggerPluginDynamicOptions(
dynamicSelectParams || {
plugin_id: '',
provider: '',
action: '',
parameter: '',
credential_id: '',
},
formItemType === FormTypeEnum.dynamicSelect,
)
const dynamicOptions = useMemo(() => {
if (!dynamicOptionsData?.options)
return []
return dynamicOptionsData.options.map(option => ({
name: typeof option.label === 'string' ? option.label : renderI18nObject(option.label),
value: option.value,
}))
}, [dynamicOptionsData, renderI18nObject])
const show = useMemo(() => {
return show_on.every((condition) => {
const conditionValue = values[condition.variable]
return conditionValue === condition.value
return watchedValues[condition.variable] === condition.value
})
}, [values, show_on])
}, [watchedValues, show_on])
const booleanRadioValue = useMemo(() => {
if (value === null || value === undefined)
@@ -124,7 +165,7 @@ const BaseField = ({
</div>
<div className={cn(inputContainerClassName)}>
{
formSchema.type === FormTypeEnum.textInput && (
[FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes(formItemType) && (
<Input
id={field.name}
name={field.name}
@@ -134,41 +175,12 @@ const BaseField = ({
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
type={getInputType(formItemType)}
/>
)
}
{
formSchema.type === FormTypeEnum.secretInput && (
<Input
id={field.name}
name={field.name}
type='password'
className={cn(inputClassName)}
value={value || ''}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
/>
)
}
{
formSchema.type === FormTypeEnum.textNumber && (
<Input
id={field.name}
name={field.name}
type='number'
className={cn(inputClassName)}
value={value || ''}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={disabled}
placeholder={memorizedPlaceholder}
/>
)
}
{
formSchema.type === FormTypeEnum.select && (
formItemType === FormTypeEnum.select && (
<PureSelect
value={value}
onChange={v => field.handleChange(v)}
@@ -180,7 +192,23 @@ const BaseField = ({
)
}
{
formSchema.type === FormTypeEnum.radio && (
formItemType === FormTypeEnum.dynamicSelect && (
<PortalSelect
value={value}
onSelect={(item: any) => field.handleChange(item.value)}
readonly={disabled || isDynamicOptionsLoading}
placeholder={
isDynamicOptionsLoading
? 'Loading options...'
: memorizedPlaceholder || 'Select an option'
}
items={dynamicOptions}
popupClassName="z-[9999]"
/>
)
}
{
formItemType === FormTypeEnum.radio && (
<div className={cn(
memorizedOptions.length < 3 ? 'flex items-center space-x-2' : 'space-y-2',
)}>
@@ -189,21 +217,14 @@ const BaseField = ({
<div
key={option.value}
className={cn(
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center gap-2 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
disabled && 'cursor-not-allowed opacity-50',
inputClassName,
)}
onClick={() => !disabled && field.handleChange(option.value)}
>
{
formSchema.showRadioUI && (
<RadioE
className='mr-2'
isChecked={value === option.value}
/>
)
}
{showRadioUI && <RadioE isChecked={value === option.value} />}
{option.label}
</div>
))
@@ -212,13 +233,13 @@ const BaseField = ({
)
}
{
formSchema.type === FormTypeEnum.boolean && (
formItemType === FormTypeEnum.boolean && (
<Radio.Group
className='flex w-fit items-center'
className='flex w-fit items-center gap-1'
value={booleanRadioValue}
onChange={val => field.handleChange(val === 1)}
>
<Radio value={1} className='!mr-1'>True</Radio>
<Radio value={1}>True</Radio>
<Radio value={0}>False</Radio>
</Radio.Group>
)
@@ -233,9 +254,7 @@ const BaseField = ({
<span className='break-all'>
{renderI18nObject(formSchema?.help as any)}
</span>
{
<RiExternalLinkLine className='ml-1 h-3 w-3' />
}
<RiExternalLinkLine className='ml-1 h-3 w-3' />
</a>
)
}

View File

@@ -32,6 +32,8 @@ export type BaseFormProps = {
ref?: FormRef
disabled?: boolean
formFromProps?: AnyFormApi
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void
preventDefaultSubmit?: boolean
} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'>
const BaseForm = ({
@@ -45,6 +47,8 @@ const BaseForm = ({
ref,
disabled,
formFromProps,
onSubmit,
preventDefaultSubmit = false,
}: BaseFormProps) => {
const initialDefaultValues = useMemo(() => {
if (defaultValues)
@@ -114,9 +118,18 @@ const BaseForm = ({
if (!formSchemas?.length)
return null
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (preventDefaultSubmit) {
e.preventDefault()
e.stopPropagation()
}
onSubmit?.(e)
}
return (
<form
className={cn(formClassName)}
onSubmit={handleSubmit}
>
{formSchemas.map(renderFieldWrapper)}
</form>

View File

@@ -62,6 +62,13 @@ export type FormSchema = {
validators?: AnyValidators
showRadioUI?: boolean
disabled?: boolean
dynamicSelectParams?: {
plugin_id: string
provider: string
action: string
parameter: string
credential_id: string
}
}
export type FormValues = Record<string, any>

View File

@@ -28,6 +28,7 @@ export type Item = {
name: string
isGroup?: boolean
disabled?: boolean
extra?: React.ReactNode
} & Record<string, any>
export type ISelectProps = {
@@ -348,6 +349,7 @@ const PortalSelect: FC<PortalSelectProps> = ({
onOpenChange={setOpen}
placement='bottom-start'
offset={4}
triggerPopupSameWidth={true}
>
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className='w-full'>
{renderTrigger
@@ -375,7 +377,7 @@ const PortalSelect: FC<PortalSelectProps> = ({
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
<div
className={classNames('max-h-60 overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-none sm:text-sm', popupInnerClassName)}
className={classNames('max-h-60 overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-none sm:text-sm', popupInnerClassName)}
>
{items.map((item: Item) => (
<div
@@ -402,6 +404,7 @@ const PortalSelect: FC<PortalSelectProps> = ({
{!hideChecked && item.value === value && (
<RiCheckLine className='h-4 w-4 shrink-0 text-text-accent' />
)}
{item.extra}
</div>
))}
</div>

View File

@@ -2,6 +2,7 @@ export enum AuthCategory {
tool = 'tool',
datasource = 'datasource',
model = 'model',
trigger = 'trigger',
}
export type PluginPayload = {

View File

@@ -1,5 +1,5 @@
'use client'
import React from 'react'
import React, { useEffect } from 'react'
import type { FC } from 'react'
import DetailHeader from './detail-header'
import EndpointList from './endpoint-list'
@@ -11,6 +11,7 @@ import { TriggerEventsList } from './trigger-events-list'
import Drawer from '@/app/components/base/drawer'
import { type PluginDetail, PluginType } from '@/app/components/plugins/types'
import cn from '@/utils/classnames'
import { usePluginStore } from './store'
type Props = {
detail?: PluginDetail
@@ -28,6 +29,12 @@ const PluginDetailPanel: FC<Props> = ({
onHide()
onUpdate()
}
const { setDetail } = usePluginStore()
useEffect(() => {
if (detail)
setDetail(detail)
}, [detail])
if (!detail)
return null
@@ -52,8 +59,8 @@ const PluginDetailPanel: FC<Props> = ({
<div className='grow overflow-y-auto'>
{detail.declaration.category === PluginType.trigger && (
<>
<SubscriptionList detail={detail} />
<TriggerEventsList detail={detail} />
<SubscriptionList />
<TriggerEventsList />
</>
)}
{!!detail.declaration.tool && <ActionList detail={detail} />}

View File

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

View File

@@ -17,11 +17,10 @@ import {
useCreateTriggerSubscriptionBuilder,
useVerifyTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import type { PluginDetail } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { usePluginStore } from '../../store'
type Props = {
pluginDetail: PluginDetail
onClose: () => void
onSuccess: () => void
}
@@ -31,9 +30,9 @@ enum ApiKeyStep {
Configuration = 'configuration',
}
const ApiKeyAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
export const ApiKeyCreateModal = ({ onClose, onSuccess }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
// State
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(ApiKeyStep.Verify)
const [subscriptionName, setSubscriptionName] = useState('')
@@ -50,9 +49,9 @@ const ApiKeyAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
// Get provider name and schemas
const providerName = `${pluginDetail.plugin_id}/${pluginDetail.declaration.name}`
const credentialsSchema = pluginDetail.declaration.trigger?.credentials_schema || []
const parametersSchema = pluginDetail.declaration.trigger?.subscription_schema?.parameters_schema || []
const providerName = `${detail?.plugin_id}/${detail?.declaration.name}`
const credentialsSchema = detail?.declaration.trigger?.credentials_schema || []
const parametersSchema = detail?.declaration.trigger?.subscription_schema?.parameters_schema || []
const handleVerify = () => {
const credentialsFormValues = credentialsFormRef.current?.getFormValues({}) || { values: {}, isCheckValidated: false }
@@ -310,5 +309,3 @@ const ApiKeyAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
</Modal>
)
}
export default ApiKeyAddModal

View File

@@ -0,0 +1,320 @@
'use client'
import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
import { BaseForm } from '@/app/components/base/form/components/base'
import type { FormRefObject } from '@/app/components/base/form/types'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import {
useBuildTriggerSubscription,
useCreateTriggerSubscriptionBuilder,
useTriggerSubscriptionBuilderLogs,
useVerifyTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import { RiLoader2Line } from '@remixicon/react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore } from '../../store'
import LogViewer from '../log-viewer'
type Props = {
onClose: () => void
createType: SupportedCreationMethods
}
const CREDENTIAL_TYPE_MAP: Record<SupportedCreationMethods, TriggerCredentialTypeEnum> = {
[SupportedCreationMethods.APIKEY]: TriggerCredentialTypeEnum.ApiKey,
[SupportedCreationMethods.OAUTH]: TriggerCredentialTypeEnum.Oauth2,
[SupportedCreationMethods.MANUAL]: TriggerCredentialTypeEnum.Unauthorized,
}
enum ApiKeyStep {
Verify = 'verify',
Configuration = 'configuration',
}
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('pluginTrigger.modal.steps.verify')} />
<div className='h-px w-3 shrink-0 bg-divider-deep'></div>
<StatusStep isActive={currentStep === ApiKeyStep.Configuration} text={t('pluginTrigger.modal.steps.configuration')} />
</div>
}
export const CommonCreateModal = ({ onClose, createType }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const [currentStep, setCurrentStep] = useState<ApiKeyStep>(createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration)
const [subscriptionName, setSubscriptionName] = useState('')
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
const [verificationError, setVerificationError] = useState<string>('')
const { mutate: verifyCredentials, isPending: isVerifyingCredentials } = useVerifyTriggerSubscriptionBuilder()
const { mutate: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder()
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
const providerName = `${detail?.plugin_id}/${detail?.declaration.name}`
const propertiesSchema = detail?.declaration.trigger.subscription_schema.properties_schema || [] // manual
const propertiesFormRef = React.useRef<FormRefObject>(null)
const parametersSchema = detail?.declaration.trigger?.subscription_schema?.parameters_schema || [] // apikey and oauth
const parametersFormRef = React.useRef<FormRefObject>(null)
const credentialsSchema = detail?.declaration.trigger?.credentials_schema || []
const credentialsFormRef = React.useRef<FormRefObject>(null)
const { data: logData } = useTriggerSubscriptionBuilderLogs(
providerName,
subscriptionBuilder?.id || '',
{
enabled: createType === SupportedCreationMethods.MANUAL && !!subscriptionBuilder?.id,
refetchInterval: 3000,
},
)
useEffect(() => {
if (!subscriptionBuilder) {
createBuilder(
{
provider: providerName,
credential_type: CREDENTIAL_TYPE_MAP[createType],
},
{
onSuccess: (response) => {
const builder = response.subscription_builder
setSubscriptionBuilder(builder)
},
onError: (error) => {
Toast.notify({
type: 'error',
message: t('pluginTrigger.modal.errors.createFailed'),
})
console.error('Failed to create subscription builder:', error)
},
},
)
}
}, [createBuilder, providerName, subscriptionBuilder, t])
const handleVerify = () => {
const credentialsFormValues = credentialsFormRef.current?.getFormValues({}) || { values: {}, isCheckValidated: false }
const credentials = credentialsFormValues.values
if (!Object.keys(credentials).length) {
Toast.notify({
type: 'error',
message: 'Please fill in all required credentials',
})
return
}
setVerificationError('')
verifyCredentials(
{
provider: providerName,
subscriptionBuilderId: subscriptionBuilder?.id || '',
credentials,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('pluginTrigger.modal.apiKey.verify.success'),
})
setCurrentStep(ApiKeyStep.Configuration)
},
onError: (error: any) => {
setVerificationError(error?.message || t('pluginTrigger.modal.apiKey.verify.error'))
},
},
)
}
const handleCreate = () => {
if (!subscriptionName.trim()) {
Toast.notify({
type: 'error',
message: t('pluginTrigger.modal.form.subscriptionName.required'),
})
return
}
if (!subscriptionBuilder)
return
const parameterForm = parametersFormRef.current?.getFormValues({}) || { values: {}, isCheckValidated: false }
// console.log('formValues', formValues)
// if (!formValues.isCheckValidated) {
// Toast.notify({
// type: 'error',
// message: t('pluginTrigger.modal.form.properties.required'),
// })
// return
// }
buildSubscription(
{
provider: providerName,
subscriptionBuilderId: subscriptionBuilder.id,
name: subscriptionName,
parameters: { ...parameterForm.values, events: ['*'] },
// properties: formValues.values,
},
{
onSuccess: () => {
Toast.notify({
type: 'success',
message: 'Subscription created successfully',
})
// onSuccess()
onClose()
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || t('pluginTrigger.modal.errors.createFailed'),
})
},
},
)
}
const handleConfirm = () => {
if (currentStep === ApiKeyStep.Verify)
handleVerify()
else
handleCreate()
}
return (
<Modal
title={t(`pluginTrigger.modal.${createType === SupportedCreationMethods.APIKEY ? 'apiKey' : createType.toLowerCase()}.title`)}
confirmButtonText={
currentStep === ApiKeyStep.Verify
? isVerifyingCredentials ? t('pluginTrigger.modal.common.verifying') : t('pluginTrigger.modal.common.verify')
: isBuilding ? t('pluginTrigger.modal.common.creating') : t('pluginTrigger.modal.common.create')
}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
>
{createType === SupportedCreationMethods.APIKEY && <MultiSteps currentStep={currentStep} />}
{currentStep === ApiKeyStep.Verify && (
<>
{credentialsSchema.length > 0 && (
<div className='mb-4'>
<BaseForm
formSchemas={credentialsSchema}
ref={credentialsFormRef}
labelClassName='system-sm-medium mb-2 block text-text-primary'
preventDefaultSubmit={true}
/>
</div>
)}
{verificationError && (
<div className='bg-state-destructive-bg mb-4 rounded-lg border border-state-destructive-border p-3'>
<div className='text-state-destructive-text system-xs-medium'>
{verificationError}
</div>
</div>
)}
</>
)}
{currentStep === ApiKeyStep.Configuration && <div className='max-h-[70vh] overflow-y-auto'>
<div className='mb-6'>
<label className='system-sm-medium mb-2 block text-text-primary'>
{t('pluginTrigger.modal.form.subscriptionName.label')}
</label>
<Input
value={subscriptionName}
onChange={e => setSubscriptionName(e.target.value)}
placeholder={t('pluginTrigger.modal.form.subscriptionName.placeholder')}
/>
</div>
<div className='mb-6'>
<label className='system-sm-medium mb-2 block text-text-primary'>
{t('pluginTrigger.modal.form.callbackUrl.label')}
</label>
<div className='relative'>
<Input
value={subscriptionBuilder?.endpoint}
readOnly
className='pr-12'
placeholder={t('pluginTrigger.modal.form.callbackUrl.placeholder')}
/>
<CopyFeedbackNew className='absolute right-1 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary' content={subscriptionBuilder?.endpoint || ''} />
</div>
<div className='system-xs-regular mt-1 text-text-tertiary'>
{t('pluginTrigger.modal.form.callbackUrl.description')}
</div>
</div>
{createType !== SupportedCreationMethods.MANUAL && parametersSchema.length > 0 && (
<BaseForm
formSchemas={parametersSchema.map(schema => ({
...schema,
dynamicSelectParams: schema.type === FormTypeEnum.dynamicSelect ? {
plugin_id: detail?.plugin_id || '',
provider: providerName,
action: 'provider',
parameter: schema.name,
credential_id: subscriptionBuilder?.id || '',
} : undefined,
}))}
ref={parametersFormRef}
labelClassName='system-sm-medium mb-2 block text-text-primary'
/>
)}
{createType === SupportedCreationMethods.MANUAL && <>
{propertiesSchema.length > 0 && (
<div className='mb-6'>
<BaseForm
formSchemas={propertiesSchema}
ref={propertiesFormRef}
labelClassName='system-sm-medium mb-2 block text-text-primary'
/>
</div>
)}
<div className='mb-6'>
<div className='mb-3 flex items-center gap-2'>
<div className='system-xs-medium-uppercase text-text-tertiary'>
REQUESTS HISTORY
</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'>
Awaiting request from {detail?.declaration.name}...
</div>
</div>
<LogViewer logs={logData?.logs || []} />
</div>
</>}
</div>}
</Modal>
)
}

View File

@@ -0,0 +1,209 @@
import { ActionButton } from '@/app/components/base/action-button'
import { Button } from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { PortalSelect } from '@/app/components/base/select'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { openOAuthPopup } from '@/hooks/use-oauth'
import { useInitiateTriggerOAuth, useTriggerOAuthConfig, useTriggerProviderInfo } from '@/service/use-triggers'
import cn from '@/utils/classnames'
import { RiAddLine, RiCloseLine, RiEqualizer2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SupportedCreationMethods } from '../../../types'
import { usePluginStore } from '../../store'
import { CommonCreateModal } from './common-modal'
import { OAuthClientSettingsModal } from './oauth-client'
export const CreateModal = () => {
const { t } = useTranslation()
return (
<Modal
isShow
// onClose={onClose}
className='!max-w-[520px] p-6'
wrapperClassName='!z-[1002]'
>
<div className='flex items-center justify-between pb-3'>
<h3 className='text-lg font-semibold text-text-primary'>
{t('pluginTrigger.modal.oauth.title')}
</h3>
<ActionButton
// onClick={onClose}
>
<RiCloseLine className='h-4 w-4' />
</ActionButton>
</div>
</Modal>
)
}
export enum CreateButtonType {
FULL_BUTTON = 'full-button',
ICON_BUTTON = 'icon-button',
}
type Props = {
className?: string
buttonType?: CreateButtonType
}
export const DEFAULT_METHOD = 'default'
/**
* 区分创建订阅的授权方式有几种
* 1. 只有一种授权方式
* - 按钮直接显示授权方式,点击按钮展示创建订阅弹窗
* 2. 有多种授权方式
* - 下拉框显示授权方式,点击按钮展示下拉框,点击选项展示创建订阅弹窗
* 有订阅与无订阅时,按钮形态不同
* oauth 的授权类型:
* - 是否配置 client_id 和 client_secret
* - 未配置则点击按钮去配置
* - 已配置则点击按钮去创建
* - 固定展示设置按钮
*/
export const CreateSubscriptionButton = ({ buttonType = CreateButtonType.FULL_BUTTON }: Props) => {
const { t } = useTranslation()
const [selectedCreateType, setSelectedCreateType] = useState<SupportedCreationMethods | null>(null)
const detail = usePluginStore(state => state.detail)
const provider = `${detail?.plugin_id}/${detail?.declaration.name}`
const { data: providerInfo } = useTriggerProviderInfo(provider, !!detail?.plugin_id && !!detail?.declaration.name)
const supportedMethods = providerInfo?.supported_creation_methods || []
const { data: oauthConfig } = useTriggerOAuthConfig(provider, supportedMethods.includes(SupportedCreationMethods.OAUTH))
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
const methodType = supportedMethods.length === 1 ? supportedMethods[0] : DEFAULT_METHOD
const [isShowClientSettingsModal, {
setTrue: showClientSettingsModal,
setFalse: hideClientSettingsModal,
}] = useBoolean(false)
const buttonTextMap = useMemo(() => {
return {
[SupportedCreationMethods.OAUTH]: t('pluginTrigger.subscription.createButton.oauth'),
[SupportedCreationMethods.APIKEY]: t('pluginTrigger.subscription.createButton.apiKey'),
[SupportedCreationMethods.MANUAL]: t('pluginTrigger.subscription.createButton.manual'),
[DEFAULT_METHOD]: t('pluginTrigger.subscription.empty.button'),
}
}, [t])
const onClickClientSettings = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.preventDefault()
showClientSettingsModal()
}
const allOptions = [
{
value: SupportedCreationMethods.OAUTH,
name: t('pluginTrigger.subscription.addType.options.oauth.title'),
extra: <ActionButton onClick={onClickClientSettings}><RiEqualizer2Line className='h-4 w-4 text-text-tertiary' /></ActionButton>,
show: supportedMethods.includes(SupportedCreationMethods.OAUTH),
},
{
value: SupportedCreationMethods.APIKEY,
name: t('pluginTrigger.subscription.addType.options.apiKey.title'),
show: supportedMethods.includes(SupportedCreationMethods.APIKEY),
},
{
value: SupportedCreationMethods.MANUAL,
name: t('pluginTrigger.subscription.addType.options.manual.description'), // 使用 description 作为标题
tooltip: <Tooltip popupContent={t('pluginTrigger.subscription.addType.options.manual.tip')} />,
show: supportedMethods.includes(SupportedCreationMethods.MANUAL),
},
]
const onChooseCreateType = (type: SupportedCreationMethods) => {
if (type === SupportedCreationMethods.OAUTH) {
if (oauthConfig?.configured) {
initiateOAuth(provider, {
onSuccess: (response) => {
openOAuthPopup(response.authorization_url, (callbackData) => {
if (callbackData) {
Toast.notify({
type: 'success',
message: t('pluginTrigger.modal.oauth.authorized'),
})
setSelectedCreateType(SupportedCreationMethods.OAUTH)
}
})
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || t('pluginTrigger.modal.errors.authFailed'),
})
},
})
}
else {
showClientSettingsModal()
}
}
else {
setSelectedCreateType(type)
}
}
const onClickCreate = (e: React.MouseEvent<HTMLButtonElement>) => {
if (methodType === DEFAULT_METHOD)
return
e.stopPropagation()
e.preventDefault()
onChooseCreateType(methodType)
}
if (!supportedMethods.length)
return null
return <>
<PortalSelect
readonly={methodType !== DEFAULT_METHOD}
renderTrigger={() => {
return buttonType === CreateButtonType.FULL_BUTTON ? (
<Button
variant='primary'
size='medium'
className='w-full'
onClick={onClickCreate}
>
<RiAddLine className='mr-2 h-4 w-4' />
{buttonTextMap[methodType]}
{methodType === SupportedCreationMethods.OAUTH
&& <ActionButton onClick={onClickClientSettings}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</ActionButton>
}
</Button>
) : <ActionButton onClick={onClickCreate}>
<RiAddLine className='h-4 w-4' />
</ActionButton>
}}
triggerClassName='h-8'
popupClassName={cn('z-[1000]')}
value={methodType}
items={allOptions.filter(option => option.show)}
onSelect={item => onChooseCreateType(item.value as any)}
/>
{selectedCreateType && (
<CommonCreateModal
createType={selectedCreateType}
onClose={() => setSelectedCreateType(null)}
/>
)}
{isShowClientSettingsModal && (
<OAuthClientSettingsModal
oauthConfig={oauthConfig}
onClose={hideClientSettingsModal}
showOAuthCreateModal={() => setSelectedCreateType(SupportedCreationMethods.OAUTH)}
/>
)}
</>
}

View File

@@ -14,23 +14,23 @@ import {
useCreateTriggerSubscriptionBuilder,
useTriggerSubscriptionBuilderLogs,
} from '@/service/use-triggers'
import type { PluginDetail } from '@/app/components/plugins/types'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
import { BaseForm } from '@/app/components/base/form/components/base'
import ActionButton from '@/app/components/base/action-button'
import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
import type { FormRefObject } from '@/app/components/base/form/types'
import LogViewer from './log-viewer'
import LogViewer from '../log-viewer'
import { usePluginStore } from '../../store'
type Props = {
pluginDetail: PluginDetail
onClose: () => void
onSuccess: () => void
}
const ManualAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
export const ManualCreateModal = ({ onClose, onSuccess }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const [subscriptionName, setSubscriptionName] = useState('')
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
@@ -38,8 +38,8 @@ const ManualAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
const { mutate: createBuilder /* isPending: isCreatingBuilder */ } = useCreateTriggerSubscriptionBuilder()
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
const providerName = `${pluginDetail.plugin_id}/${pluginDetail.declaration.name}`
const propertiesSchema = pluginDetail.declaration.trigger.subscription_schema.properties_schema || []
const providerName = `${detail?.plugin_id}/${detail?.declaration.name}`
const propertiesSchema = detail?.declaration.trigger.subscription_schema.properties_schema || []
const propertiesFormRef = React.useRef<FormRefObject>(null)
const { data: logData } = useTriggerSubscriptionBuilderLogs(
@@ -193,7 +193,7 @@ const ManualAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
<RiLoader2Line className='h-full w-full animate-spin' />
</div>
<div className='system-xs-regular text-text-tertiary'>
Awaiting request from {pluginDetail.declaration.name}...
Awaiting request from {detail?.declaration.name}...
</div>
</div>
@@ -217,5 +217,3 @@ const ManualAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
</Modal>
)
}
export default ManualAddModal

View File

@@ -0,0 +1,238 @@
'use client'
import Button from '@/app/components/base/button'
import Form from '@/app/components/base/form/form-scenarios/auth'
import type { FormRefObject } from '@/app/components/base/form/types'
import Modal from '@/app/components/base/modal/modal'
import Toast from '@/app/components/base/toast'
import type { TriggerOAuthClientParams, TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { openOAuthPopup } from '@/hooks/use-oauth'
import {
useConfigureTriggerOAuth,
useDeleteTriggerOAuth,
useInitiateTriggerOAuth,
useVerifyTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import {
RiClipboardLine,
RiInformation2Fill,
} from '@remixicon/react'
import React, { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore } from '../../store'
type Props = {
oauthConfig?: TriggerOAuthConfig
onClose: () => void
showOAuthCreateModal: () => void
}
enum AuthorizationStatusEnum {
Pending = 'pending',
Success = 'success',
Failed = 'failed',
}
enum ClientTypeEnum {
Default = 'default',
Custom = 'custom',
}
export const OAuthClientSettingsModal = ({ oauthConfig, onClose, showOAuthCreateModal }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const [subscriptionBuilder, setSubscriptionBuilder] = useState<TriggerSubscriptionBuilder | undefined>()
const [authorizationStatus, setAuthorizationStatus] = useState<AuthorizationStatusEnum>()
const [clientType, setClientType] = useState<ClientTypeEnum>(oauthConfig?.custom_enabled ? ClientTypeEnum.Custom : ClientTypeEnum.Default)
const clientFormRef = React.useRef<FormRefObject>(null)
const providerName = useMemo(() => !detail ? '' : `${detail?.plugin_id}/${detail?.declaration.name}`, [detail])
const clientSchema = detail?.declaration.trigger?.oauth_schema?.client_schema || []
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
const { mutate: verifyBuilder } = useVerifyTriggerSubscriptionBuilder()
const { mutate: configureOAuth } = useConfigureTriggerOAuth()
const { mutate: deleteOAuth } = useDeleteTriggerOAuth()
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('pluginTrigger.modal.oauth.authorization.authSuccess'),
})
onClose()
showOAuthCreateModal()
}
})
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || t('pluginTrigger.modal.errors.authFailed'),
})
},
})
}
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('pluginTrigger.modal.oauth.configuration.success'),
})
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || t('pluginTrigger.modal.oauth.configuration.failed'),
})
},
})
}
const handleSave = (needAuth: boolean) => {
const clientParams = clientFormRef.current?.getFormValues({})?.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__]'
configureOAuth({
provider: providerName,
client_params: clientParams as TriggerOAuthClientParams,
enabled: clientType === ClientTypeEnum.Custom,
}, {
onSuccess: () => {
if (needAuth)
handleAuthorization()
else
onClose()
Toast.notify({
type: 'success',
message: t('pluginTrigger.modal.oauth.configuration.success'),
})
},
onError: (error: any) => {
Toast.notify({
type: 'error',
message: error?.message || t('pluginTrigger.modal.oauth.configuration.failed'),
})
},
})
}
return (
<Modal
title={t('pluginTrigger.modal.oauth.title')}
confirmButtonText={authorizationStatus === AuthorizationStatusEnum.Pending ? t('pluginTrigger.modal.common.authorizing')
: authorizationStatus === AuthorizationStatusEnum.Success ? t('pluginTrigger.modal.oauth.authorization.waitingJump') : t('plugin.auth.saveAndAuth')}
cancelButtonText={t('plugin.auth.saveOnly')}
extraButtonText={t('common.operation.cancel')}
showExtraButton
extraButtonVariant='secondary'
onExtraButtonClick={onClose}
onClose={onClose}
onCancel={() => handleSave(false)}
onConfirm={() => handleSave(true)}
footerSlot={
oauthConfig?.custom_enabled && oauthConfig?.params && (
<div className='grow'>
<Button
variant='secondary'
className='text-components-button-destructive-secondary-text'
// disabled={disabled || doingAction || !editValues}
onClick={handleRemove}
>
{t('common.operation.remove')}
</Button>
</div>
)
}
>
<span className='system-sm-semibold mb-2 text-text-secondary'>OAuth Client</span>
<div className='mb-4 flex w-full items-start justify-between gap-2'>
{[ClientTypeEnum.Default, ClientTypeEnum.Custom].map(option => (
<OptionCard
key={option}
title={option}
onSelect={() => setClientType(option)}
selected={clientType === option}
className="flex-1"
/>
))}
</div>
{oauthConfig?.redirect_uri && (
<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' />
</div>
<div className='flex-1 text-text-secondary'>
<div className='system-sm-regular whitespace-pre-wrap leading-4'>
{t('pluginTrigger.modal.oauthRedirectInfo')}
</div>
<div className='system-sm-medium my-1.5 break-all leading-4'>
{oauthConfig.redirect_uri}
</div>
<Button
variant='secondary'
size='small'
onClick={() => {
navigator.clipboard.writeText(oauthConfig.redirect_uri)
Toast.notify({
type: 'success',
message: t('common.actionMsg.copySuccessfully'),
})
}}>
<RiClipboardLine className='mr-1 h-[14px] w-[14px]' />
{t('common.operation.copy')}
</Button>
</div>
</div>
)}
{clientType === ClientTypeEnum.Custom && clientSchema.length > 0 && (
<Form
formSchemas={clientSchema}
ref={clientFormRef}
defaultValues={oauthConfig?.params}
/>
)}
</Modal >
)
}

View File

@@ -15,15 +15,14 @@ import type { FormRefObject } from '@/app/components/base/form/types'
import {
useBuildTriggerSubscription,
useInitiateTriggerOAuth,
useTriggerOAuthConfig,
useVerifyTriggerSubscriptionBuilder,
} from '@/service/use-triggers'
import type { PluginDetail } from '@/app/components/plugins/types'
import ActionButton from '@/app/components/base/action-button'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { usePluginStore } from '../../store'
type Props = {
pluginDetail: PluginDetail
oauthConfig?: TriggerOAuthConfig
onClose: () => void
onSuccess: () => void
}
@@ -39,9 +38,9 @@ enum AuthorizationStatusEnum {
Failed = 'failed',
}
const OAuthAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
export const OAuthCreateModal = ({ oauthConfig, onClose, onSuccess }: Props) => {
const { t } = useTranslation()
const detail = usePluginStore(state => state.detail)
const [currentStep, setCurrentStep] = useState<OAuthStepEnum>(OAuthStepEnum.Setup)
const [subscriptionName, setSubscriptionName] = useState('')
const [authorizationUrl, setAuthorizationUrl] = useState('')
@@ -51,16 +50,14 @@ const OAuthAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
const clientFormRef = React.useRef<FormRefObject>(null)
const parametersFormRef = React.useRef<FormRefObject>(null)
const providerName = `${pluginDetail.plugin_id}/${pluginDetail.declaration.name}`
const clientSchema = pluginDetail.declaration.trigger?.oauth_schema?.client_schema || []
const parametersSchema = pluginDetail.declaration.trigger?.subscription_schema?.parameters_schema || []
const providerName = `${detail?.plugin_id}/${detail?.declaration.name}`
const clientSchema = detail?.declaration.trigger?.oauth_schema?.client_schema || []
const parametersSchema = detail?.declaration.trigger?.subscription_schema?.parameters_schema || []
const { mutate: initiateOAuth } = useInitiateTriggerOAuth()
const { mutate: verifyBuilder } = useVerifyTriggerSubscriptionBuilder()
const { mutate: buildSubscription, isPending: isBuilding } = useBuildTriggerSubscription()
const { data: oauthConfig } = useTriggerOAuthConfig(providerName)
useEffect(() => {
initiateOAuth(providerName, {
onSuccess: (response) => {
@@ -290,5 +287,3 @@ const OAuthAddModal = ({ pluginDetail, onClose, onSuccess }: Props) => {
</Modal>
)
}
export default OAuthAddModal

View File

@@ -5,21 +5,19 @@ import { RiEqualizer2Line } from '@remixicon/react'
import cn from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
import { ActionButton } from '@/app/components/base/action-button'
enum SubscriptionAddTypeEnum {
OAuth = 'oauth',
APIKey = 'api-key',
Manual = 'manual',
}
import { SupportedCreationMethods } from '../../../types'
import type { TriggerOAuthConfig } from '@/app/components/workflow/block-selector/types'
type Props = {
onSelect: (type: SubscriptionAddTypeEnum) => void
onSelect: (type: SupportedCreationMethods) => void
onClose: () => void
position?: 'bottom' | 'right'
className?: string
supportedMethods: SupportedCreationMethods[]
oauthConfig?: TriggerOAuthConfig
}
const AddTypeDropdown = ({ onSelect, onClose, position = 'bottom', className }: Props) => {
export const CreateTypeDropdown = ({ onSelect, onClose, position = 'bottom', className, supportedMethods }: Props) => {
const { t } = useTranslation()
const dropdownRef = useRef<HTMLDivElement>(null)
@@ -37,24 +35,29 @@ const AddTypeDropdown = ({ onSelect, onClose, position = 'bottom', className }:
// todo: show client settings
}
const options = [
const allOptions = [
{
key: SubscriptionAddTypeEnum.OAuth,
key: SupportedCreationMethods.OAUTH,
title: t('pluginTrigger.subscription.addType.options.oauth.title'),
extraContent: <ActionButton onClick={onClickClientSettings}><RiEqualizer2Line className='h-4 w-4 text-text-tertiary' /></ActionButton>,
show: supportedMethods.includes(SupportedCreationMethods.OAUTH),
},
{
key: SubscriptionAddTypeEnum.APIKey,
key: SupportedCreationMethods.APIKEY,
title: t('pluginTrigger.subscription.addType.options.apiKey.title'),
show: supportedMethods.includes(SupportedCreationMethods.APIKEY),
},
{
key: SubscriptionAddTypeEnum.Manual,
key: SupportedCreationMethods.MANUAL,
title: t('pluginTrigger.subscription.addType.options.manual.description'), // 使用 description 作为标题
tooltip: t('pluginTrigger.subscription.addType.options.manual.tip'),
show: supportedMethods.includes(SupportedCreationMethods.MANUAL),
},
]
const handleOptionClick = (type: SubscriptionAddTypeEnum) => {
const options = allOptions.filter(option => option.show)
const handleOptionClick = (type: SupportedCreationMethods) => {
onSelect(type)
}
@@ -100,5 +103,3 @@ const AddTypeDropdown = ({ onSelect, onClose, position = 'bottom', className }:
</div>
)
}
export default AddTypeDropdown

View File

@@ -1,56 +1,27 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { RiAddLine } from '@remixicon/react'
import SubscriptionCard from './subscription-card'
import SubscriptionAddModal from './subscription-add-modal'
import AddTypeDropdown from './add-type-dropdown'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import { useTriggerSubscriptions } from '@/service/use-triggers'
import type { PluginDetail } from '@/app/components/plugins/types'
import cn from '@/utils/classnames'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginStore, usePluginSubscriptionStore } from '../store'
import { CreateButtonType, CreateSubscriptionButton } from './create'
import SubscriptionCard from './subscription-card'
type Props = {
detail: PluginDetail
}
type SubscriptionAddType = 'api-key' | 'oauth' | 'manual'
export const SubscriptionList = ({ detail }: Props) => {
export const SubscriptionList = () => {
const { t } = useTranslation()
const showTopBorder = detail.declaration.tool || detail.declaration.endpoint
const detail = usePluginStore(state => state.detail)
const { data: subscriptions, isLoading, refetch } = useTriggerSubscriptions(`${detail.plugin_id}/${detail.declaration.name}`)
const showTopBorder = detail?.declaration.tool || detail?.declaration.endpoint
const provider = `${detail?.plugin_id}/${detail?.declaration.name}`
const [isShowAddModal, {
setTrue: showAddModal,
setFalse: hideAddModal,
}] = useBoolean(false)
const { data: subscriptions, isLoading, refetch } = useTriggerSubscriptions(provider, !!detail?.plugin_id && !!detail?.declaration.name)
const [selectedAddType, setSelectedAddType] = React.useState<SubscriptionAddType | null>(null)
const { setRefresh } = usePluginSubscriptionStore()
const [isShowAddDropdown, {
setTrue: showAddDropdown,
setFalse: hideAddDropdown,
}] = useBoolean(false)
const handleAddTypeSelect = (type: SubscriptionAddType) => {
setSelectedAddType(type)
hideAddDropdown()
showAddModal()
}
const handleModalClose = () => {
hideAddModal()
setSelectedAddType(null)
}
const handleRefreshList = () => {
refetch()
}
useEffect(() => {
if (refetch)
setRefresh(refetch)
}, [refetch])
if (isLoading) {
return (
@@ -66,64 +37,28 @@ export const SubscriptionList = ({ detail }: Props) => {
return (
<div className={cn('border-divider-subtle px-4 py-2', showTopBorder && 'border-t')}>
{!hasSubscriptions ? (
<div className='relative w-full'>
<Button
variant='primary'
size='medium'
className='w-full'
onClick={showAddDropdown}
>
<RiAddLine className='mr-2 h-4 w-4' />
{t('pluginTrigger.subscription.empty.button')}
</Button>
{isShowAddDropdown && (
<AddTypeDropdown
onSelect={handleAddTypeSelect}
onClose={hideAddDropdown}
<div className='relative mb-3 flex items-center justify-between'>
{
hasSubscriptions
&& <div className='flex items-center gap-1'>
<span className='system-sm-semibold-uppercase text-text-secondary'>
{t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })}
</span>
<Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} />
</div>
}
<CreateSubscriptionButton buttonType={hasSubscriptions ? CreateButtonType.ICON_BUTTON : CreateButtonType.FULL_BUTTON} />
</div>
{hasSubscriptions
&& <div className='flex flex-col gap-1'>
{subscriptions?.map(subscription => (
<SubscriptionCard
key={subscription.id}
data={subscription}
/>
)}
</div>
) : (
<>
<div className='system-sm-semibold-uppercase relative mb-3 flex items-center justify-between'>
<div className='flex items-center gap-1'>
<span className='system-sm-semibold text-text-secondary'>
{t('pluginTrigger.subscription.listNum', { num: subscriptions?.length || 0 })}
</span>
<Tooltip popupContent={t('pluginTrigger.subscription.list.tip')} />
</div>
<ActionButton onClick={showAddDropdown}>
<RiAddLine className='h-4 w-4' />
</ActionButton>
{isShowAddDropdown && (
<AddTypeDropdown
onSelect={handleAddTypeSelect}
onClose={hideAddDropdown}
/>
)}
</div>
<div className='flex flex-col gap-1'>
{subscriptions?.map(subscription => (
<SubscriptionCard
key={subscription.id}
data={subscription}
onRefresh={handleRefreshList}
/>
))}
</div>
</>
)}
{isShowAddModal && selectedAddType && (
<SubscriptionAddModal
type={selectedAddType}
pluginDetail={detail}
onClose={handleModalClose}
onSuccess={handleRefreshList}
/>
)}
))}
</div>}
</div>
)
}

View File

@@ -1,56 +0,0 @@
'use client'
import React from 'react'
// import { useTranslation } from 'react-i18next'
// import Modal from '@/app/components/base/modal'
import ManualAddModal from './manual-add-modal'
import ApiKeyAddModal from './api-key-add-modal'
import OAuthAddModal from './oauth-add-modal'
import type { PluginDetail } from '@/app/components/plugins/types'
type SubscriptionAddType = 'api-key' | 'oauth' | 'manual'
type Props = {
type: SubscriptionAddType
pluginDetail: PluginDetail
onClose: () => void
onSuccess: () => void
}
const SubscriptionAddModal = ({ type, pluginDetail, onClose, onSuccess }: Props) => {
// const { t } = useTranslation()
const renderModalContent = () => {
switch (type) {
case 'manual':
return (
<ManualAddModal
pluginDetail={pluginDetail}
onClose={onClose}
onSuccess={onSuccess}
/>
)
case 'api-key':
return (
<ApiKeyAddModal
pluginDetail={pluginDetail}
onClose={onClose}
onSuccess={onSuccess}
/>
)
case 'oauth':
return (
<OAuthAddModal
pluginDetail={pluginDetail}
onClose={onClose}
onSuccess={onSuccess}
/>
)
default:
return null
}
}
return renderModalContent()
}
export default SubscriptionAddModal

View File

@@ -1,30 +1,30 @@
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import Toast from '@/app/components/base/toast'
import Indicator from '@/app/components/header/indicator'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import { useDeleteTriggerSubscription } from '@/service/use-triggers'
import cn from '@/utils/classnames'
import {
RiDeleteBinLine,
RiWebhookLine,
} from '@remixicon/react'
import ActionButton from '@/app/components/base/action-button'
import Indicator from '@/app/components/header/indicator'
import Confirm from '@/app/components/base/confirm'
import Toast from '@/app/components/base/toast'
import { useDeleteTriggerSubscription } from '@/service/use-triggers'
import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
import cn from '@/utils/classnames'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { usePluginSubscriptionStore } from '../store'
type Props = {
data: TriggerSubscription
onRefresh: () => void
}
const SubscriptionCard = ({ data, onRefresh }: Props) => {
const SubscriptionCard = ({ data }: Props) => {
const { t } = useTranslation()
const [isShowDeleteModal, {
setTrue: showDeleteModal,
setFalse: hideDeleteModal,
}] = useBoolean(false)
const { refresh } = usePluginSubscriptionStore()
const { mutate: deleteSubscription, isPending: isDeleting } = useDeleteTriggerSubscription()
@@ -35,7 +35,7 @@ const SubscriptionCard = ({ data, onRefresh }: Props) => {
type: 'success',
message: t('pluginTrigger.subscription.list.item.actions.deleteConfirm.title'),
})
onRefresh()
refresh?.()
hideDeleteModal()
},
onError: (error: any) => {

View File

@@ -1,17 +1,12 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import ToolItem from '@/app/components/tools/provider/tool-item'
import type { PluginDetail } from '@/app/components/plugins/types'
import { usePluginStore } from './store'
type Props = {
detail: PluginDetail
}
export const TriggerEventsList = ({
detail,
}: Props) => {
export const TriggerEventsList = () => {
const { t } = useTranslation()
const triggers = detail.declaration.trigger?.triggers || []
const detail = usePluginStore(state => state.detail)
const triggers = detail?.declaration.trigger?.triggers || []
if (!triggers.length)
return null
@@ -27,7 +22,7 @@ export const TriggerEventsList = ({
<div className='flex flex-col gap-2'>
{triggers.map(triggerEvent => (
<ToolItem
key={`${detail.plugin_id}${triggerEvent.identity.name}`}
key={`${detail?.plugin_id}${triggerEvent.identity.name}`}
disabled={false}
// collection={provider}
// @ts-expect-error triggerEvent.identity.label is Record<Locale, string>

View File

@@ -202,6 +202,12 @@ export type PluginManifestInMarket = {
from: Dependency['type']
}
export enum SupportedCreationMethods {
OAUTH = 'OAUTH',
APIKEY = 'APIKEY',
MANUAL = 'MANUAL',
}
export type PluginDetail = {
id: string
created_at: string

View File

@@ -1,4 +1,4 @@
import type { PluginMeta } from '../../plugins/types'
import type { PluginMeta, SupportedCreationMethods } from '../../plugins/types'
import type { Collection, Trigger } from '../../tools/types'
import type { TypeWithI18N } from '../../base/form/types'
@@ -151,6 +151,7 @@ export type TriggerProviderApiEntity = {
tags: string[]
plugin_id?: string
plugin_unique_identifier: string
supported_creation_methods: SupportedCreationMethods[]
credentials_schema: TriggerCredentialField[]
oauth_client_schema: TriggerCredentialField[]
subscription_schema: TriggerSubscriptionSchema

View File

@@ -63,8 +63,6 @@ import {
import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
import useInspectVarsCrud from './use-inspect-vars-crud'
import { getNodeUsedVars } from '../nodes/_base/components/variable/utils'
import { deleteWebhookUrl } from '@/service/apps'
import { useStore as useAppStore } from '@/app/components/app/store'
// Entry node deletion restriction has been removed to allow empty workflows
@@ -76,7 +74,7 @@ export const useNodesInteractions = () => {
const reactflow = useReactFlow()
const { store: workflowHistoryStore } = useWorkflowHistoryStore()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const appId = useAppStore.getState().appDetail?.id
const {
checkNestedParallelLimit,
getAfterNodesInSameBranch,
@@ -592,17 +590,6 @@ export const useNodesInteractions = () => {
deleteNodeInspectorVars(nodeId)
if (currentNode.data.type === BlockEnum.TriggerWebhook) {
if (appId) {
try {
deleteWebhookUrl({ appId, nodeId })
}
catch (error) {
console.error('Failed to delete webhook URL:', error)
}
}
}
if (currentNode.data.type === BlockEnum.Iteration) {
const iterationChildren = nodes.filter(node => node.parentId === currentNode.id)

View File

@@ -0,0 +1,247 @@
import nodeDefault from '../default'
import type { ScheduleTriggerNodeType } from '../types'
// Mock translation function
const mockT = (key: string, params?: any) => {
if (key.includes('fieldRequired')) return `${params?.field} is required`
if (key.includes('invalidCronExpression')) return 'Invalid cron expression'
if (key.includes('invalidTimezone')) return 'Invalid timezone'
if (key.includes('noValidExecutionTime')) return 'No valid execution time'
if (key.includes('executionTimeCalculationError')) return 'Execution time calculation error'
return key
}
describe('Schedule Trigger Default - Backward Compatibility', () => {
describe('Enhanced Cron Expression Support', () => {
it('should accept enhanced month abbreviations', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: '0 9 1 JAN *', // January 1st at 9 AM
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
it('should accept enhanced day abbreviations', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: '0 15 * * MON', // Every Monday at 3 PM
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
it('should accept predefined expressions', () => {
const predefinedExpressions = ['@daily', '@weekly', '@monthly', '@yearly', '@hourly']
predefinedExpressions.forEach((expr) => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: expr,
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})
it('should accept special characters', () => {
const specialExpressions = [
'0 9 ? * 1', // ? wildcard
'0 12 * * 7', // Sunday as 7
'0 15 L * *', // Last day of month
]
specialExpressions.forEach((expr) => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: expr,
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})
it('should maintain backward compatibility with legacy expressions', () => {
const legacyExpressions = [
'15 10 1 * *', // Monthly 1st at 10:15
'0 0 * * 0', // Weekly Sunday midnight
'*/5 * * * *', // Every 5 minutes
'0 9-17 * * 1-5', // Business hours weekdays
'30 14 * * 1', // Monday 14:30
'0 0 1,15 * *', // 1st and 15th midnight
]
legacyExpressions.forEach((expr) => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: expr,
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})
})
describe('Error Detection and Validation', () => {
it('should detect invalid enhanced syntax', () => {
const invalidExpressions = [
'0 12 * JANUARY *', // Full month name not supported
'0 12 * * MONDAY', // Full day name not supported
'0 12 32 JAN *', // Invalid day with month abbreviation
'@invalid', // Invalid predefined expression
'0 12 1 INVALID *', // Invalid month abbreviation
'0 12 * * INVALID', // Invalid day abbreviation
]
invalidExpressions.forEach((expr) => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: expr,
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toContain('Invalid cron expression')
})
})
it('should handle execution time calculation errors gracefully', () => {
// Test with an expression that contains invalid date (Feb 30th)
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: '0 0 30 2 *', // Feb 30th (invalid date)
}
const result = nodeDefault.checkValid(payload, mockT)
// Should be an invalid expression error since cron-parser detects Feb 30th as invalid
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('Invalid cron expression')
})
})
describe('Timezone Integration', () => {
it('should validate with various timezones', () => {
const timezones = ['UTC', 'America/New_York', 'Asia/Tokyo', 'Europe/London']
timezones.forEach((timezone) => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone,
cron_expression: '0 12 * * *', // Daily noon
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})
it('should reject invalid timezones', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'Invalid/Timezone',
cron_expression: '0 12 * * *',
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toContain('Invalid timezone')
})
})
describe('Visual Mode Compatibility', () => {
it('should maintain visual mode validation', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'visual',
timezone: 'UTC',
frequency: 'daily',
visual_config: {
time: '9:00 AM',
},
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
it('should validate weekly configuration', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'visual',
timezone: 'UTC',
frequency: 'weekly',
visual_config: {
time: '2:30 PM',
weekdays: ['mon', 'wed', 'fri'],
},
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
it('should validate monthly configuration', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'visual',
timezone: 'UTC',
frequency: 'monthly',
visual_config: {
time: '11:30 AM',
monthly_days: [1, 15, 'last'],
},
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})
describe('Edge Cases and Robustness', () => {
it('should handle empty/whitespace cron expressions', () => {
const emptyExpressions = ['', ' ', '\t\n ']
emptyExpressions.forEach((expr) => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: expr,
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toMatch(/(Invalid cron expression|required)/)
})
})
it('should validate whitespace-padded expressions', () => {
const payload: ScheduleTriggerNodeType = {
mode: 'cron',
timezone: 'UTC',
cron_expression: ' 0 12 * * * ', // Padded with whitespace
}
const result = nodeDefault.checkValid(payload, mockT)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})
})

View File

@@ -31,7 +31,7 @@ describe('Schedule Trigger Node Default', () => {
describe('Basic Configuration', () => {
it('should have correct default value', () => {
expect(nodeDefault.defaultValue.mode).toBe('visual')
expect(nodeDefault.defaultValue.frequency).toBe('weekly')
expect(nodeDefault.defaultValue.frequency).toBe('daily')
})
it('should have empty prev nodes', () => {

View File

@@ -2,9 +2,9 @@ import type { ScheduleTriggerNodeType } from './types'
export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> => ({
mode: 'visual',
frequency: 'weekly',
frequency: 'daily',
visual_config: {
time: '11:30 AM',
time: '12:00 AM',
weekdays: ['sun'],
on_minute: 0,
monthly_days: [1],
@@ -12,7 +12,7 @@ export const getDefaultScheduleConfig = (): Partial<ScheduleTriggerNodeType> =>
})
export const getDefaultVisualConfig = () => ({
time: '11:30 AM',
time: '12:00 AM',
weekdays: ['sun'],
on_minute: 0,
monthly_days: [1],

View File

@@ -55,7 +55,7 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
{t('workflow.nodes.triggerSchedule.frequencyLabel')}
</label>
<FrequencySelector
frequency={inputs.frequency}
frequency={inputs.frequency || 'daily'}
onChange={handleFrequencyChange}
/>
</div>
@@ -75,7 +75,7 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
timezone={inputs.timezone}
value={inputs.visual_config?.time
? dayjs(`1/1/2000 ${inputs.visual_config.time}`)
: dayjs('1/1/2000 11:30 AM')
: dayjs('1/1/2000 12:00 AM')
}
onChange={(time) => {
if (time) {
@@ -84,7 +84,7 @@ const Panel: FC<NodePanelProps<ScheduleTriggerNodeType>> = ({
}
}}
onClear={() => {
handleTimeChange('11:30 AM')
handleTimeChange('12:00 AM')
}}
placeholder={t('workflow.nodes.triggerSchedule.selectTime')}
/>

View File

@@ -13,7 +13,7 @@ export type VisualConfig = {
export type ScheduleTriggerNodeType = CommonNodeType & {
mode: ScheduleMode
frequency: ScheduleFrequency
frequency?: ScheduleFrequency
cron_expression?: string
visual_config?: VisualConfig
timezone: string

View File

@@ -14,7 +14,7 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
return {
...payload,
mode: payload.mode || 'visual',
frequency: payload.frequency || 'weekly',
frequency: payload.frequency || 'daily',
timezone: userProfile.timezone || 'UTC',
visual_config: {
...getDefaultVisualConfig(),
@@ -43,6 +43,7 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
on_minute: inputs.visual_config?.on_minute ?? 0,
},
},
cron_expression: undefined,
}
setInputs(newInputs)
}, [inputs, setInputs])
@@ -51,6 +52,8 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
const newInputs = {
...inputs,
cron_expression: value,
frequency: undefined,
visual_config: undefined,
}
setInputs(newInputs)
}, [inputs, setInputs])
@@ -62,6 +65,7 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
...inputs.visual_config,
weekdays,
},
cron_expression: undefined,
}
setInputs(newInputs)
}, [inputs, setInputs])
@@ -73,6 +77,7 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
...inputs.visual_config,
time,
},
cron_expression: undefined,
}
setInputs(newInputs)
}, [inputs, setInputs])
@@ -84,6 +89,7 @@ const useConfig = (id: string, payload: ScheduleTriggerNodeType) => {
...inputs.visual_config,
on_minute,
},
cron_expression: undefined,
}
setInputs(newInputs)
}, [inputs, setInputs])

View File

@@ -11,6 +11,36 @@ describe('cron-parser', () => {
expect(isValidCronExpression('0 0 1,15 * *')).toBe(true)
})
test('validates enhanced dayOfWeek syntax', () => {
expect(isValidCronExpression('0 9 * * 7')).toBe(true) // Sunday as 7
expect(isValidCronExpression('0 9 * * SUN')).toBe(true) // Sunday abbreviation
expect(isValidCronExpression('0 9 * * MON')).toBe(true) // Monday abbreviation
expect(isValidCronExpression('0 9 * * MON-FRI')).toBe(true) // Range with abbreviations
expect(isValidCronExpression('0 9 * * SUN,WED,FRI')).toBe(true) // List with abbreviations
})
test('validates enhanced month syntax', () => {
expect(isValidCronExpression('0 9 1 JAN *')).toBe(true) // January abbreviation
expect(isValidCronExpression('0 9 1 DEC *')).toBe(true) // December abbreviation
expect(isValidCronExpression('0 9 1 JAN-MAR *')).toBe(true) // Range with abbreviations
expect(isValidCronExpression('0 9 1 JAN,JUN,DEC *')).toBe(true) // List with abbreviations
})
test('validates special characters', () => {
expect(isValidCronExpression('0 9 ? * 1')).toBe(true) // ? wildcard
expect(isValidCronExpression('0 9 L * *')).toBe(true) // Last day of month
expect(isValidCronExpression('0 9 * * 1#1')).toBe(true) // First Monday of month
expect(isValidCronExpression('0 9 * * 1L')).toBe(true) // Last Monday of month
})
test('validates predefined expressions', () => {
expect(isValidCronExpression('@yearly')).toBe(true)
expect(isValidCronExpression('@monthly')).toBe(true)
expect(isValidCronExpression('@weekly')).toBe(true)
expect(isValidCronExpression('@daily')).toBe(true)
expect(isValidCronExpression('@hourly')).toBe(true)
})
test('rejects invalid cron expressions', () => {
expect(isValidCronExpression('')).toBe(false)
expect(isValidCronExpression('15 10 1')).toBe(false) // Not enough fields
@@ -19,13 +49,18 @@ describe('cron-parser', () => {
expect(isValidCronExpression('15 25 1 * *')).toBe(false) // Invalid hour
expect(isValidCronExpression('15 10 32 * *')).toBe(false) // Invalid day
expect(isValidCronExpression('15 10 1 13 *')).toBe(false) // Invalid month
expect(isValidCronExpression('15 10 1 * 7')).toBe(false) // Invalid day of week
expect(isValidCronExpression('15 10 1 * 8')).toBe(false) // Invalid day of week
expect(isValidCronExpression('15 10 1 INVALID *')).toBe(false) // Invalid month abbreviation
expect(isValidCronExpression('15 10 1 * INVALID')).toBe(false) // Invalid day abbreviation
expect(isValidCronExpression('@invalid')).toBe(false) // Invalid predefined expression
})
test('handles edge cases', () => {
expect(isValidCronExpression(' 15 10 1 * * ')).toBe(true) // Whitespace
expect(isValidCronExpression('0 0 29 2 *')).toBe(true) // Feb 29 (valid in leap years)
expect(isValidCronExpression('59 23 31 12 6')).toBe(true) // Max values
expect(isValidCronExpression('0 0 29 FEB *')).toBe(true) // Feb 29 with month abbreviation
expect(isValidCronExpression('59 23 31 DEC SAT')).toBe(true) // Max values with abbreviations
})
})
@@ -168,6 +203,10 @@ describe('cron-parser', () => {
expect(result).toHaveLength(5)
result.forEach((date) => {
// Since we're using UTC timezone in this test, the returned dates should
// be in the future relative to the current time
// Note: our implementation returns dates in "user timezone representation"
// but for UTC, this should match the expected behavior
expect(date.getTime()).toBeGreaterThan(Date.now())
})
@@ -202,21 +241,269 @@ describe('cron-parser', () => {
})
})
describe('enhanced syntax tests', () => {
test('handles month abbreviations correctly', () => {
const result = parseCronExpression('0 12 1 JAN *') // First day of January at noon
expect(result).toHaveLength(5)
result.forEach((date) => {
expect(date.getMonth()).toBe(0) // January
expect(date.getDate()).toBe(1)
expect(date.getHours()).toBe(12)
expect(date.getMinutes()).toBe(0)
})
})
test('handles day abbreviations correctly', () => {
const result = parseCronExpression('0 14 * * MON') // Every Monday at 14:00
expect(result).toHaveLength(5)
result.forEach((date) => {
expect(date.getDay()).toBe(1) // Monday
expect(date.getHours()).toBe(14)
expect(date.getMinutes()).toBe(0)
})
})
test('handles Sunday as both 0 and 7', () => {
const result0 = parseCronExpression('0 10 * * 0') // Sunday as 0
const result7 = parseCronExpression('0 10 * * 7') // Sunday as 7
const resultSUN = parseCronExpression('0 10 * * SUN') // Sunday as SUN
expect(result0).toHaveLength(5)
expect(result7).toHaveLength(5)
expect(resultSUN).toHaveLength(5)
// All should return Sundays
result0.forEach(date => expect(date.getDay()).toBe(0))
result7.forEach(date => expect(date.getDay()).toBe(0))
resultSUN.forEach(date => expect(date.getDay()).toBe(0))
})
test('handles question mark wildcard', () => {
const resultStar = parseCronExpression('0 9 * * 1') // Using *
const resultQuestion = parseCronExpression('0 9 ? * 1') // Using ?
expect(resultStar).toHaveLength(5)
expect(resultQuestion).toHaveLength(5)
// Both should return Mondays at 9:00
resultStar.forEach((date) => {
expect(date.getDay()).toBe(1)
expect(date.getHours()).toBe(9)
})
resultQuestion.forEach((date) => {
expect(date.getDay()).toBe(1)
expect(date.getHours()).toBe(9)
})
})
test('handles predefined expressions', () => {
const daily = parseCronExpression('@daily')
const weekly = parseCronExpression('@weekly')
const monthly = parseCronExpression('@monthly')
expect(daily).toHaveLength(5)
expect(weekly).toHaveLength(5)
expect(monthly).toHaveLength(5)
// @daily should be at midnight
daily.forEach((date) => {
expect(date.getHours()).toBe(0)
expect(date.getMinutes()).toBe(0)
})
// @weekly should be on Sundays at midnight
weekly.forEach((date) => {
expect(date.getDay()).toBe(0) // Sunday
expect(date.getHours()).toBe(0)
expect(date.getMinutes()).toBe(0)
})
// @monthly should be on the 1st of each month at midnight
monthly.forEach((date) => {
expect(date.getDate()).toBe(1)
expect(date.getHours()).toBe(0)
expect(date.getMinutes()).toBe(0)
})
})
})
describe('edge cases and error handling', () => {
test('handles complex month/day combinations', () => {
// Test Feb 29 with month abbreviation
const result = parseCronExpression('0 12 29 FEB *')
if (result.length > 0) {
result.forEach((date) => {
expect(date.getMonth()).toBe(1) // February
expect(date.getDate()).toBe(29)
// Should only occur in leap years
const year = date.getFullYear()
expect(year % 4).toBe(0)
})
}
})
test('handles mixed syntax correctly', () => {
// Mix of numbers and abbreviations (using only dayOfMonth OR dayOfWeek, not both)
// Test 1: Month abbreviations with specific day
const result1 = parseCronExpression('30 14 15 JAN,JUN,DEC *')
expect(result1.length).toBeGreaterThan(0)
result1.forEach((date) => {
expect(date.getDate()).toBe(15) // Should be 15th day
expect([0, 5, 11]).toContain(date.getMonth()) // Jan, Jun, Dec
expect(date.getHours()).toBe(14)
expect(date.getMinutes()).toBe(30)
})
// Test 2: Month abbreviations with weekdays
const result2 = parseCronExpression('0 9 * JAN-MAR MON-FRI')
expect(result2.length).toBeGreaterThan(0)
result2.forEach((date) => {
// Should be weekday OR in Q1 months
const isWeekday = date.getDay() >= 1 && date.getDay() <= 5
const isQ1 = [0, 1, 2].includes(date.getMonth())
expect(isWeekday || isQ1).toBe(true)
expect(date.getHours()).toBe(9)
expect(date.getMinutes()).toBe(0)
})
})
test('handles timezone edge cases', () => {
// Test with different timezones
const utcResult = parseCronExpression('0 12 * * *', 'UTC')
const nyResult = parseCronExpression('0 12 * * *', 'America/New_York')
const tokyoResult = parseCronExpression('0 12 * * *', 'Asia/Tokyo')
expect(utcResult).toHaveLength(5)
expect(nyResult).toHaveLength(5)
expect(tokyoResult).toHaveLength(5)
// All should be at noon in their respective timezones
utcResult.forEach(date => expect(date.getHours()).toBe(12))
nyResult.forEach(date => expect(date.getHours()).toBe(12))
tokyoResult.forEach(date => expect(date.getHours()).toBe(12))
})
test('timezone compatibility and DST handling', () => {
// Test DST boundary scenarios
jest.useFakeTimers()
try {
// Test 1: DST spring forward (March 2024) - America/New_York
jest.setSystemTime(new Date('2024-03-08T10:00:00Z'))
const springDST = parseCronExpression('0 2 * * *', 'America/New_York')
expect(springDST).toHaveLength(5)
springDST.forEach(date => expect([2, 3]).toContain(date.getHours()))
// Test 2: DST fall back (November 2024) - America/New_York
jest.setSystemTime(new Date('2024-11-01T10:00:00Z'))
const fallDST = parseCronExpression('0 1 * * *', 'America/New_York')
expect(fallDST).toHaveLength(5)
fallDST.forEach(date => expect(date.getHours()).toBe(1))
// Test 3: Cross-timezone consistency on same UTC moment
jest.setSystemTime(new Date('2024-06-15T12:00:00Z'))
const utcNoon = parseCronExpression('0 12 * * *', 'UTC')
const nycMorning = parseCronExpression('0 8 * * *', 'America/New_York') // 8 AM NYC = 12 PM UTC in summer
const tokyoEvening = parseCronExpression('0 21 * * *', 'Asia/Tokyo') // 9 PM Tokyo = 12 PM UTC
expect(utcNoon).toHaveLength(5)
expect(nycMorning).toHaveLength(5)
expect(tokyoEvening).toHaveLength(5)
// Verify timezone consistency - all should represent the same UTC moments
const utcTime = utcNoon[0]
const nycTime = nycMorning[0]
const tokyoTime = tokyoEvening[0]
expect(utcTime.getHours()).toBe(12)
expect(nycTime.getHours()).toBe(8)
expect(tokyoTime.getHours()).toBe(21)
}
finally {
jest.useRealTimers()
}
})
test('backward compatibility with execution-time-calculator timezone logic', () => {
// Simulate the exact usage pattern from execution-time-calculator.ts:47
const mockData = {
cron_expression: '30 14 * * 1-5', // 2:30 PM weekdays
timezone: 'America/New_York',
}
// This is the exact call from execution-time-calculator.ts
const results = parseCronExpression(mockData.cron_expression, mockData.timezone)
expect(results).toHaveLength(5)
results.forEach((date) => {
// Should be weekdays (1-5)
expect(date.getDay()).toBeGreaterThanOrEqual(1)
expect(date.getDay()).toBeLessThanOrEqual(5)
// Should be 2:30 PM in the user's timezone representation
expect(date.getHours()).toBe(14)
expect(date.getMinutes()).toBe(30)
expect(date.getSeconds()).toBe(0)
// Should be Date objects (not CronDate or other types)
expect(date).toBeInstanceOf(Date)
// Should be in the future (relative to test time)
expect(date.getTime()).toBeGreaterThan(Date.now())
})
})
test('edge case timezone handling', () => {
// Test uncommon but valid timezones
const australiaResult = parseCronExpression('0 15 * * *', 'Australia/Sydney')
const indiaResult = parseCronExpression('0 9 * * *', 'Asia/Kolkata') // UTC+5:30
const alaskaResult = parseCronExpression('0 6 * * *', 'America/Anchorage')
expect(australiaResult).toHaveLength(5)
expect(indiaResult).toHaveLength(5)
expect(alaskaResult).toHaveLength(5)
australiaResult.forEach(date => expect(date.getHours()).toBe(15))
indiaResult.forEach(date => expect(date.getHours()).toBe(9))
alaskaResult.forEach(date => expect(date.getHours()).toBe(6))
// Test invalid timezone graceful handling
const invalidTzResult = parseCronExpression('0 12 * * *', 'Invalid/Timezone')
// Should either return empty array or handle gracefully
expect(Array.isArray(invalidTzResult)).toBe(true)
})
test('gracefully handles invalid enhanced syntax', () => {
// Invalid but close to valid expressions
expect(parseCronExpression('0 12 * JANUARY *')).toEqual([]) // Full month name
expect(parseCronExpression('0 12 * * MONDAY')).toEqual([]) // Full day name
expect(parseCronExpression('0 12 32 JAN *')).toEqual([]) // Invalid day with valid month
expect(parseCronExpression('@invalid')).toEqual([]) // Invalid predefined
})
})
describe('performance tests', () => {
test('performs well for complex expressions', () => {
const start = performance.now()
// Test multiple complex expressions
// Test multiple complex expressions including new syntax
const expressions = [
'*/5 9-17 * * 1-5', // Every 5 minutes, weekdays, business hours
'0 */2 1,15 * *', // Every 2 hours on 1st and 15th
'30 14 * * 1,3,5', // Mon, Wed, Fri at 14:30
'15,45 8-18 * * 1-5', // 15 and 45 minutes past the hour, weekdays
'0 9 * JAN-MAR MON-FRI', // Weekdays in Q1 at 9:00
'0 12 ? * SUN', // Sundays at noon using ?
'@daily', // Predefined expression
'@weekly', // Predefined expression
]
expressions.forEach((expr) => {
const result = parseCronExpression(expr)
expect(result).toHaveLength(5)
expect(result.length).toBeGreaterThan(0)
expect(result.length).toBeLessThanOrEqual(5)
})
// Test quarterly expression separately (may return fewer than 5 results)
@@ -226,8 +513,8 @@ describe('cron-parser', () => {
const end = performance.now()
// Should complete within reasonable time (less than 100ms for all expressions)
expect(end - start).toBeLessThan(100)
// Should complete within reasonable time (less than 150ms for all expressions)
expect(end - start).toBeLessThan(150)
})
})
})

View File

@@ -1,229 +1,84 @@
const matchesField = (value: number, pattern: string, min: number, max: number): boolean => {
if (pattern === '*') return true
import { CronExpressionParser } from 'cron-parser'
if (pattern.includes(','))
return pattern.split(',').some(p => matchesField(value, p.trim(), min, max))
// Convert a UTC date from cron-parser to user timezone representation
// This ensures consistency with other execution time calculations
const convertToUserTimezoneRepresentation = (utcDate: Date, timezone: string): Date => {
// Get the time string in the target timezone
const userTimeStr = utcDate.toLocaleString('en-CA', {
timeZone: timezone,
hour12: false,
})
const [dateStr, timeStr] = userTimeStr.split(', ')
const [year, month, day] = dateStr.split('-').map(Number)
const [hour, minute, second] = timeStr.split(':').map(Number)
if (pattern.includes('/')) {
const [range, step] = pattern.split('/')
const stepValue = Number.parseInt(step, 10)
if (Number.isNaN(stepValue)) return false
if (range === '*') {
return value % stepValue === min % stepValue
}
else {
const rangeStart = Number.parseInt(range, 10)
if (Number.isNaN(rangeStart)) return false
return value >= rangeStart && (value - rangeStart) % stepValue === 0
}
}
if (pattern.includes('-')) {
const [start, end] = pattern.split('-').map(p => Number.parseInt(p.trim(), 10))
if (Number.isNaN(start) || Number.isNaN(end)) return false
return value >= start && value <= end
}
const numValue = Number.parseInt(pattern, 10)
if (Number.isNaN(numValue)) return false
return value === numValue
}
const expandCronField = (field: string, min: number, max: number): number[] => {
if (field === '*')
return Array.from({ length: max - min + 1 }, (_, i) => min + i)
if (field.includes(','))
return field.split(',').flatMap(p => expandCronField(p.trim(), min, max))
if (field.includes('/')) {
const [range, step] = field.split('/')
const stepValue = Number.parseInt(step, 10)
if (Number.isNaN(stepValue)) return []
const baseValues = range === '*' ? [min] : expandCronField(range, min, max)
const result: number[] = []
for (let start = baseValues[0]; start <= max; start += stepValue) {
if (start >= min && start <= max)
result.push(start)
}
return result
}
if (field.includes('-')) {
const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10))
if (Number.isNaN(start) || Number.isNaN(end)) return []
const result: number[] = []
for (let i = start; i <= end && i <= max; i++)
if (i >= min) result.push(i)
return result
}
const numValue = Number.parseInt(field, 10)
return !Number.isNaN(numValue) && numValue >= min && numValue <= max ? [numValue] : []
}
const matchesCron = (
date: Date,
minute: string,
hour: string,
dayOfMonth: string,
month: string,
dayOfWeek: string,
): boolean => {
const currentMinute = date.getMinutes()
const currentHour = date.getHours()
const currentDay = date.getDate()
const currentMonth = date.getMonth() + 1
const currentDayOfWeek = date.getDay()
// Basic time matching
if (!matchesField(currentMinute, minute, 0, 59)) return false
if (!matchesField(currentHour, hour, 0, 23)) return false
if (!matchesField(currentMonth, month, 1, 12)) return false
// Day matching logic: if both dayOfMonth and dayOfWeek are specified (not *),
// the cron should match if EITHER condition is true (OR logic)
const dayOfMonthSpecified = dayOfMonth !== '*'
const dayOfWeekSpecified = dayOfWeek !== '*'
if (dayOfMonthSpecified && dayOfWeekSpecified) {
// If both are specified, match if either matches
return matchesField(currentDay, dayOfMonth, 1, 31)
|| matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
}
else if (dayOfMonthSpecified) {
// Only day of month specified
return matchesField(currentDay, dayOfMonth, 1, 31)
}
else if (dayOfWeekSpecified) {
// Only day of week specified
return matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
}
else {
// Both are *, matches any day
return true
}
// Create a new Date object representing this time as "local" time
// This matches the behavior expected by the execution-time-calculator
return new Date(year, month - 1, day, hour, minute, second)
}
/**
* Parse a cron expression and return the next 5 execution times
*
* @param cronExpression - Standard 5-field cron expression (minute hour day month dayOfWeek)
* @param timezone - IANA timezone identifier (e.g., 'UTC', 'America/New_York')
* @returns Array of Date objects representing the next 5 execution times
*/
export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => {
if (!cronExpression || cronExpression.trim() === '')
return []
const parts = cronExpression.trim().split(/\s+/)
if (parts.length !== 5)
return []
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
// Support both 5-field format and predefined expressions
if (parts.length !== 5 && !cronExpression.startsWith('@'))
return []
try {
const nextTimes: Date[] = []
// Get user timezone current time - no browser timezone involved
const now = new Date()
const userTimeStr = now.toLocaleString('en-CA', {
timeZone: timezone,
hour12: false,
// Parse the cron expression with timezone support
// Use the actual current time for cron-parser to handle properly
const interval = CronExpressionParser.parse(cronExpression, {
tz: timezone,
})
const [dateStr, timeStr] = userTimeStr.split(', ')
const [year, monthNum, day] = dateStr.split('-').map(Number)
const [nowHour, nowMinute, nowSecond] = timeStr.split(':').map(Number)
const userToday = new Date(year, monthNum - 1, day, 0, 0, 0, 0)
const userCurrentTime = new Date(year, monthNum - 1, day, nowHour, nowMinute, nowSecond)
const isMonthlyPattern = dayOfMonth !== '*' && dayOfWeek === '*'
const isWeeklyPattern = dayOfMonth === '*' && dayOfWeek !== '*'
// Get the next 5 execution times using the take() method
const nextCronDates = interval.take(5)
let searchMonths = 12
if (isWeeklyPattern) searchMonths = 3
else if (!isMonthlyPattern) searchMonths = 2
for (let monthOffset = 0; monthOffset < searchMonths && nextTimes.length < 5; monthOffset++) {
const checkMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1)
const daysInMonth = new Date(checkMonth.getFullYear(), checkMonth.getMonth() + 1, 0).getDate()
for (let day = 1; day <= daysInMonth && nextTimes.length < 5; day++) {
const checkDate = new Date(checkMonth.getFullYear(), checkMonth.getMonth(), day)
if (minute !== '*' && hour !== '*') {
const minuteValues = expandCronField(minute, 0, 59)
const hourValues = expandCronField(hour, 0, 23)
for (const h of hourValues) {
for (const m of minuteValues) {
checkDate.setHours(h, m, 0, 0)
// Only add if execution time is in the future and matches cron pattern
if (checkDate > userCurrentTime && matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
nextTimes.push(new Date(checkDate))
}
}
}
else {
for (let h = 0; h < 24 && nextTimes.length < 5; h++) {
for (let m = 0; m < 60 && nextTimes.length < 5; m++) {
checkDate.setHours(h, m, 0, 0)
// Only add if execution time is in the future and matches cron pattern
if (checkDate > userCurrentTime && matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
nextTimes.push(new Date(checkDate))
}
}
}
}
}
return nextTimes.sort((a, b) => a.getTime() - b.getTime()).slice(0, 5)
// Convert CronDate objects to Date objects and ensure they represent
// the time in user timezone (consistent with execution-time-calculator.ts)
return nextCronDates.map((cronDate) => {
const utcDate = cronDate.toDate()
return convertToUserTimezoneRepresentation(utcDate, timezone)
})
}
catch {
// Return empty array if parsing fails
return []
}
}
const isValidCronField = (field: string, min: number, max: number): boolean => {
if (field === '*') return true
if (field.includes(','))
return field.split(',').every(p => isValidCronField(p.trim(), min, max))
if (field.includes('/')) {
const [range, step] = field.split('/')
const stepValue = Number.parseInt(step, 10)
if (Number.isNaN(stepValue) || stepValue <= 0) return false
if (range === '*') return true
return isValidCronField(range, min, max)
}
if (field.includes('-')) {
const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10))
if (Number.isNaN(start) || Number.isNaN(end)) return false
return start >= min && end <= max && start <= end
}
const numValue = Number.parseInt(field, 10)
return !Number.isNaN(numValue) && numValue >= min && numValue <= max
}
/**
* Validate a cron expression format and syntax
*
* @param cronExpression - Standard 5-field cron expression to validate
* @returns boolean indicating if the cron expression is valid
*/
export const isValidCronExpression = (cronExpression: string): boolean => {
if (!cronExpression || cronExpression.trim() === '')
return false
const parts = cronExpression.trim().split(/\s+/)
if (parts.length !== 5)
// Support both 5-field format and predefined expressions
if (parts.length !== 5 && !cronExpression.startsWith('@'))
return false
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
return (
isValidCronField(minute, 0, 59)
&& isValidCronField(hour, 0, 23)
&& isValidCronField(dayOfMonth, 1, 31)
&& isValidCronField(month, 1, 12)
&& isValidCronField(dayOfWeek, 0, 6)
)
try {
// Use cron-parser to validate the expression
CronExpressionParser.parse(cronExpression)
return true
}
catch {
return false
}
}

View File

@@ -140,4 +140,236 @@ describe('execution-time-calculator', () => {
expect(() => getNextExecutionTimes(data, 1)).not.toThrow()
})
})
describe('timezone handling and cron integration', () => {
beforeEach(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
})
afterEach(() => {
jest.useRealTimers()
})
it('cron mode timezone consistency', () => {
// Test the exact integration path with cron-parser
const data = createMockData({
mode: 'cron',
cron_expression: '0 9 * * 1-5', // 9 AM weekdays
timezone: 'America/New_York',
})
const result = getNextExecutionTimes(data, 3)
expect(result.length).toBeGreaterThan(0)
result.forEach((date) => {
// Should be weekdays
expect(date.getDay()).toBeGreaterThanOrEqual(1)
expect(date.getDay()).toBeLessThanOrEqual(5)
// Should be 9 AM in the target timezone representation
expect(date.getHours()).toBe(9)
expect(date.getMinutes()).toBe(0)
// Should be Date objects
expect(date).toBeInstanceOf(Date)
})
})
it('cron mode with enhanced syntax', () => {
// Test new cron syntax features work through execution-time-calculator
const testCases = [
{
expression: '@daily',
expectedHour: 0,
expectedMinute: 0,
},
{
expression: '0 15 * * MON',
expectedHour: 15,
expectedMinute: 0,
},
{
expression: '30 10 1 JAN *',
expectedHour: 10,
expectedMinute: 30,
},
]
testCases.forEach(({ expression, expectedHour, expectedMinute }) => {
const data = createMockData({
mode: 'cron',
cron_expression: expression,
timezone: 'UTC',
})
const result = getNextExecutionTimes(data, 1)
if (result.length > 0) {
expect(result[0].getHours()).toBe(expectedHour)
expect(result[0].getMinutes()).toBe(expectedMinute)
}
})
})
it('timezone consistency across different modes', () => {
const timezone = 'Europe/London'
// Test visual mode with timezone
const visualData = createMockData({
mode: 'visual',
frequency: 'daily',
visual_config: { time: '2:00 PM' },
timezone,
})
// Test cron mode with same timezone
const cronData = createMockData({
mode: 'cron',
cron_expression: '0 14 * * *', // 2:00 PM
timezone,
})
const visualResult = getNextExecutionTimes(visualData, 1)
const cronResult = getNextExecutionTimes(cronData, 1)
expect(visualResult).toHaveLength(1)
expect(cronResult).toHaveLength(1)
// Both should show 2 PM (14:00) in their timezone representation
expect(visualResult[0].getHours()).toBe(14)
expect(cronResult[0].getHours()).toBe(14)
})
it('DST boundary handling in cron mode', () => {
// Test around DST transition
jest.setSystemTime(new Date('2024-03-08T10:00:00Z')) // Before DST in US
const data = createMockData({
mode: 'cron',
cron_expression: '0 2 * * *', // 2 AM daily
timezone: 'America/New_York',
})
const result = getNextExecutionTimes(data, 5)
expect(result.length).toBeGreaterThan(0)
// During DST spring forward, 2 AM becomes 3 AM
// This is correct behavior - the cron-parser library handles DST properly
result.forEach((date) => {
// Should be either 2 AM (non-DST days) or 3 AM (DST transition day)
expect([2, 3]).toContain(date.getHours())
expect(date.getMinutes()).toBe(0)
})
})
it('invalid cron expression handling', () => {
const data = createMockData({
mode: 'cron',
cron_expression: '',
timezone: 'UTC',
})
const result = getNextExecutionTimes(data, 5)
expect(result).toEqual([])
// Test getNextExecutionTime with invalid cron
const timeString = getNextExecutionTime(data)
expect(timeString).toBe('--')
})
it('cron vs visual mode consistency check', () => {
// Compare equivalent expressions in both modes
const cronDaily = createMockData({
mode: 'cron',
cron_expression: '0 0 * * *', // Daily at midnight
timezone: 'UTC',
})
const visualDaily = createMockData({
mode: 'visual',
frequency: 'daily',
visual_config: { time: '12:00 AM' },
timezone: 'UTC',
})
const cronResult = getNextExecutionTimes(cronDaily, 1)
const visualResult = getNextExecutionTimes(visualDaily, 1)
if (cronResult.length > 0 && visualResult.length > 0) {
expect(cronResult[0].getHours()).toBe(visualResult[0].getHours())
expect(cronResult[0].getMinutes()).toBe(visualResult[0].getMinutes())
}
})
})
describe('weekly and monthly frequency timezone handling', () => {
beforeEach(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
})
afterEach(() => {
jest.useRealTimers()
})
it('weekly frequency with timezone', () => {
const data = createMockData({
frequency: 'weekly',
visual_config: {
time: '9:00 AM',
weekdays: ['mon', 'wed', 'fri'],
},
timezone: 'Asia/Tokyo',
})
const result = getNextExecutionTimes(data, 3)
expect(result.length).toBeGreaterThan(0)
result.forEach((date) => {
expect([1, 3, 5]).toContain(date.getDay()) // Mon, Wed, Fri
expect(date.getHours()).toBe(9)
expect(date.getMinutes()).toBe(0)
})
})
it('monthly frequency with timezone', () => {
const data = createMockData({
frequency: 'monthly',
visual_config: {
time: '11:30 AM',
monthly_days: [1, 15, 'last'],
},
timezone: 'America/Los_Angeles',
})
const result = getNextExecutionTimes(data, 3)
expect(result.length).toBeGreaterThan(0)
result.forEach((date) => {
expect(date.getHours()).toBe(11)
expect(date.getMinutes()).toBe(30)
// Should be on specified days (1st, 15th, or last day of month)
const day = date.getDate()
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
expect(day === 1 || day === 15 || day === lastDay).toBe(true)
})
})
it('hourly frequency with timezone', () => {
const data = createMockData({
frequency: 'hourly',
visual_config: { on_minute: 45 },
timezone: 'Europe/Berlin',
})
const result = getNextExecutionTimes(data, 3)
expect(result.length).toBeGreaterThan(0)
result.forEach((date) => {
expect(date.getMinutes()).toBe(45)
expect(date.getSeconds()).toBe(0)
})
})
})
})

View File

@@ -48,7 +48,7 @@ export const getNextExecutionTimes = (data: ScheduleTriggerNodeType, count: numb
}
const times: Date[] = []
const defaultTime = data.visual_config?.time || '11:30 AM'
const defaultTime = data.visual_config?.time || '12:00 AM'
// Get "today" in user's timezone for display purposes
const now = new Date()
@@ -244,6 +244,12 @@ export const getFormattedExecutionTimes = (data: ScheduleTriggerNodeType, count:
}
export const getNextExecutionTime = (data: ScheduleTriggerNodeType): string => {
// Return placeholder for cron mode with empty or invalid expression
if (data.mode === 'cron') {
if (!data.cron_expression || !isValidCronExpression(data.cron_expression))
return '--'
}
const times = getFormattedExecutionTimes(data, 1)
if (times.length === 0) {
const userCurrentTime = getUserTimezoneCurrentTime(data.timezone)

View File

@@ -0,0 +1,349 @@
import { isValidCronExpression, parseCronExpression } from './cron-parser'
import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
import type { ScheduleTriggerNodeType } from '../types'
// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility
describe('cron-parser + execution-time-calculator integration', () => {
beforeAll(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'))
})
afterAll(() => {
jest.useRealTimers()
})
const createCronData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
id: 'test-cron',
type: 'schedule-trigger',
mode: 'cron',
frequency: 'daily',
timezone: 'UTC',
...overrides,
})
describe('backward compatibility validation', () => {
it('maintains exact behavior for legacy cron expressions', () => {
const legacyExpressions = [
'15 10 1 * *', // Monthly 1st at 10:15
'0 0 * * 0', // Weekly Sunday midnight
'*/5 * * * *', // Every 5 minutes
'0 9-17 * * 1-5', // Business hours weekdays
'30 14 * * 1', // Monday 14:30
'0 0 1,15 * *', // 1st and 15th midnight
]
legacyExpressions.forEach((expression) => {
// Test direct cron-parser usage
const directResult = parseCronExpression(expression, 'UTC')
expect(directResult).toHaveLength(5)
expect(isValidCronExpression(expression)).toBe(true)
// Test through execution-time-calculator
const data = createCronData({ cron_expression: expression })
const calculatorResult = getNextExecutionTimes(data, 5)
expect(calculatorResult).toHaveLength(5)
// Results should be identical
directResult.forEach((directDate, index) => {
const calcDate = calculatorResult[index]
expect(calcDate.getTime()).toBe(directDate.getTime())
expect(calcDate.getHours()).toBe(directDate.getHours())
expect(calcDate.getMinutes()).toBe(directDate.getMinutes())
})
})
})
it('validates timezone handling consistency', () => {
const timezones = ['UTC', 'America/New_York', 'Asia/Tokyo', 'Europe/London']
const expression = '0 12 * * *' // Daily noon
timezones.forEach((timezone) => {
// Direct cron-parser call
const directResult = parseCronExpression(expression, timezone)
// Through execution-time-calculator
const data = createCronData({ cron_expression: expression, timezone })
const calculatorResult = getNextExecutionTimes(data, 5)
expect(directResult).toHaveLength(5)
expect(calculatorResult).toHaveLength(5)
// All results should show noon (12:00) in their respective timezone
directResult.forEach(date => expect(date.getHours()).toBe(12))
calculatorResult.forEach(date => expect(date.getHours()).toBe(12))
// Cross-validation: results should be identical
directResult.forEach((directDate, index) => {
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
})
})
})
it('error handling consistency', () => {
const invalidExpressions = [
'', // Empty string
' ', // Whitespace only
'60 10 1 * *', // Invalid minute
'15 25 1 * *', // Invalid hour
'15 10 32 * *', // Invalid day
'15 10 1 13 *', // Invalid month
'15 10 1', // Too few fields
'15 10 1 * * *', // Too many fields
'invalid expression', // Completely invalid
]
invalidExpressions.forEach((expression) => {
// Direct cron-parser calls
expect(isValidCronExpression(expression)).toBe(false)
expect(parseCronExpression(expression, 'UTC')).toEqual([])
// Through execution-time-calculator
const data = createCronData({ cron_expression: expression })
const result = getNextExecutionTimes(data, 5)
expect(result).toEqual([])
// getNextExecutionTime should return '--' for invalid cron
const timeString = getNextExecutionTime(data)
expect(timeString).toBe('--')
})
})
})
describe('enhanced features integration', () => {
it('month and day abbreviations work end-to-end', () => {
const enhancedExpressions = [
{ expr: '0 9 1 JAN *', month: 0, day: 1, hour: 9 }, // January 1st 9 AM
{ expr: '0 15 * * MON', weekday: 1, hour: 15 }, // Monday 3 PM
{ expr: '30 10 15 JUN,DEC *', month: [5, 11], day: 15, hour: 10, minute: 30 }, // Jun/Dec 15th
{ expr: '0 12 * JAN-MAR *', month: [0, 1, 2], hour: 12 }, // Q1 noon
]
enhancedExpressions.forEach(({ expr, month, day, weekday, hour, minute = 0 }) => {
// Validate through both paths
expect(isValidCronExpression(expr)).toBe(true)
const directResult = parseCronExpression(expr, 'UTC')
const data = createCronData({ cron_expression: expr })
const calculatorResult = getNextExecutionTimes(data, 3)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Validate expected properties
const validateDate = (date: Date) => {
expect(date.getHours()).toBe(hour)
expect(date.getMinutes()).toBe(minute)
if (month !== undefined) {
if (Array.isArray(month))
expect(month).toContain(date.getMonth())
else
expect(date.getMonth()).toBe(month)
}
if (day !== undefined)
expect(date.getDate()).toBe(day)
if (weekday !== undefined)
expect(date.getDay()).toBe(weekday)
}
directResult.forEach(validateDate)
calculatorResult.forEach(validateDate)
})
})
it('predefined expressions work through execution-time-calculator', () => {
const predefExpressions = [
{ expr: '@daily', hour: 0, minute: 0 },
{ expr: '@weekly', hour: 0, minute: 0, weekday: 0 }, // Sunday
{ expr: '@monthly', hour: 0, minute: 0, day: 1 }, // 1st of month
{ expr: '@yearly', hour: 0, minute: 0, month: 0, day: 1 }, // Jan 1st
]
predefExpressions.forEach(({ expr, hour, minute, weekday, day, month }) => {
expect(isValidCronExpression(expr)).toBe(true)
const data = createCronData({ cron_expression: expr })
const result = getNextExecutionTimes(data, 3)
expect(result.length).toBeGreaterThan(0)
result.forEach((date) => {
expect(date.getHours()).toBe(hour)
expect(date.getMinutes()).toBe(minute)
if (weekday !== undefined) expect(date.getDay()).toBe(weekday)
if (day !== undefined) expect(date.getDate()).toBe(day)
if (month !== undefined) expect(date.getMonth()).toBe(month)
})
})
})
it('special characters integration', () => {
const specialExpressions = [
'0 9 ? * 1', // ? wildcard for day
'0 12 * * 7', // Sunday as 7
'0 15 L * *', // Last day of month
]
specialExpressions.forEach((expr) => {
// Should validate and parse successfully
expect(isValidCronExpression(expr)).toBe(true)
const directResult = parseCronExpression(expr, 'UTC')
const data = createCronData({ cron_expression: expr })
const calculatorResult = getNextExecutionTimes(data, 2)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Results should be consistent
expect(calculatorResult[0].getHours()).toBe(directResult[0].getHours())
expect(calculatorResult[0].getMinutes()).toBe(directResult[0].getMinutes())
})
})
})
describe('DST and timezone edge cases', () => {
it('handles DST transitions consistently', () => {
// Test around DST spring forward (March 2024)
jest.setSystemTime(new Date('2024-03-08T10:00:00Z'))
const expression = '0 2 * * *' // 2 AM daily (problematic during DST)
const timezone = 'America/New_York'
const directResult = parseCronExpression(expression, timezone)
const data = createCronData({ cron_expression: expression, timezone })
const calculatorResult = getNextExecutionTimes(data, 5)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Both should handle DST gracefully
// During DST spring forward, 2 AM becomes 3 AM - this is correct behavior
directResult.forEach(date => expect([2, 3]).toContain(date.getHours()))
calculatorResult.forEach(date => expect([2, 3]).toContain(date.getHours()))
// Results should be identical
directResult.forEach((directDate, index) => {
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
})
})
it('complex timezone scenarios', () => {
const scenarios = [
{ tz: 'Asia/Kolkata', expr: '30 14 * * *', expectedHour: 14, expectedMinute: 30 }, // UTC+5:30
{ tz: 'Australia/Adelaide', expr: '0 8 * * *', expectedHour: 8, expectedMinute: 0 }, // UTC+9:30/+10:30
{ tz: 'Pacific/Kiritimati', expr: '0 12 * * *', expectedHour: 12, expectedMinute: 0 }, // UTC+14
]
scenarios.forEach(({ tz, expr, expectedHour, expectedMinute }) => {
const directResult = parseCronExpression(expr, tz)
const data = createCronData({ cron_expression: expr, timezone: tz })
const calculatorResult = getNextExecutionTimes(data, 2)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Validate expected time
directResult.forEach((date) => {
expect(date.getHours()).toBe(expectedHour)
expect(date.getMinutes()).toBe(expectedMinute)
})
calculatorResult.forEach((date) => {
expect(date.getHours()).toBe(expectedHour)
expect(date.getMinutes()).toBe(expectedMinute)
})
// Cross-validate consistency
expect(calculatorResult[0].getTime()).toBe(directResult[0].getTime())
})
})
})
describe('performance and reliability', () => {
it('handles high-frequency expressions efficiently', () => {
const highFreqExpressions = [
'*/1 * * * *', // Every minute
'*/5 * * * *', // Every 5 minutes
'0,15,30,45 * * * *', // Every 15 minutes
]
highFreqExpressions.forEach((expr) => {
const start = performance.now()
// Test both direct and through calculator
const directResult = parseCronExpression(expr, 'UTC')
const data = createCronData({ cron_expression: expr })
const calculatorResult = getNextExecutionTimes(data, 5)
const end = performance.now()
expect(directResult).toHaveLength(5)
expect(calculatorResult).toHaveLength(5)
expect(end - start).toBeLessThan(100) // Should be fast
// Results should be consistent
directResult.forEach((directDate, index) => {
expect(calculatorResult[index].getTime()).toBe(directDate.getTime())
})
})
})
it('stress test with complex expressions', () => {
const complexExpressions = [
'15,45 8-18 1,15 JAN-MAR MON-FRI', // Business hours, specific days, Q1, weekdays
'0 */2 ? * SUN#1,SUN#3', // First and third Sunday, every 2 hours
'30 9 L * *', // Last day of month, 9:30 AM
]
complexExpressions.forEach((expr) => {
if (isValidCronExpression(expr)) {
const directResult = parseCronExpression(expr, 'America/New_York')
const data = createCronData({
cron_expression: expr,
timezone: 'America/New_York',
})
const calculatorResult = getNextExecutionTimes(data, 3)
expect(directResult.length).toBeGreaterThan(0)
expect(calculatorResult.length).toBeGreaterThan(0)
// Validate consistency where results exist
const minLength = Math.min(directResult.length, calculatorResult.length)
for (let i = 0; i < minLength; i++)
expect(calculatorResult[i].getTime()).toBe(directResult[i].getTime())
}
})
})
})
describe('format compatibility', () => {
it('getNextExecutionTime formatting consistency', () => {
const testCases = [
{ expr: '0 9 * * *', timezone: 'UTC' },
{ expr: '30 14 * * 1-5', timezone: 'America/New_York' },
{ expr: '@daily', timezone: 'Asia/Tokyo' },
]
testCases.forEach(({ expr, timezone }) => {
const data = createCronData({ cron_expression: expr, timezone })
const timeString = getNextExecutionTime(data)
// Should return a formatted time string, not '--'
expect(timeString).not.toBe('--')
expect(typeof timeString).toBe('string')
expect(timeString.length).toBeGreaterThan(0)
// Should contain expected format elements
expect(timeString).toMatch(/\d+:\d+/) // Time format
expect(timeString).toMatch(/AM|PM/) // 12-hour format
expect(timeString).toMatch(/\d{4}/) // Year
})
})
})
})

View File

@@ -26,14 +26,6 @@ const nodeDefault: NodeDefault<WebhookTriggerNodeType> = {
return nodes.filter(type => type !== BlockEnum.Start)
},
checkValid(payload: WebhookTriggerNodeType, t: any) {
// Validate webhook configuration
if (!payload.webhook_url) {
return {
isValid: false,
errorMessage: t('workflow.nodes.triggerWebhook.validation.webhookUrlRequired'),
}
}
// Validate parameter types for params and body
const parametersWithTypes = [
...(payload.params || []),

View File

@@ -7,6 +7,11 @@ const translation = {
description: 'Create your first subscription to start receiving events',
button: 'New subscription',
},
createButton: {
oauth: 'New subscription with OAuth',
apiKey: 'New subscription with API Key',
manual: 'Paste URL to create a new subscription',
},
list: {
title: 'Subscriptions',
addButton: 'Add',
@@ -98,10 +103,12 @@ const translation = {
waitingAuth: 'Waiting for authorization...',
authSuccess: 'Authorization successful',
authFailed: 'Authorization failed',
waitingJump: 'Authorized, waiting for jump',
},
configuration: {
title: 'Configure Subscription',
description: 'Set up your subscription parameters after authorization',
success: 'OAuth configuration successful',
},
},
manual: {

View File

@@ -16,6 +16,7 @@ const translation = {
agent: 'Agent Strategy',
extension: 'Extension',
bundle: 'Bundle',
trigger: 'Trigger',
},
search: 'Search',
allCategories: 'All Categories',

View File

@@ -0,0 +1,180 @@
const translation = {
subscription: {
title: 'サブスクリプション',
listNum: '{{num}} サブスクリプション',
empty: {
title: 'サブスクリプションがありません',
description: 'イベントの受信を開始するために最初のサブスクリプションを作成してください',
button: '新しいサブスクリプション',
},
createButton: {
oauth: 'OAuth で新しいサブスクリプション',
apiKey: 'API キーで新しいサブスクリプション',
manual: 'URL を貼り付けて新しいサブスクリプションを作成',
},
list: {
title: 'サブスクリプション',
addButton: '追加',
tip: 'サブスクリプション経由でイベントを受信',
item: {
enabled: '有効',
disabled: '無効',
credentialType: {
api_key: 'API キー',
oauth2: 'OAuth',
unauthorized: '手動',
},
actions: {
delete: '削除',
deleteConfirm: {
title: 'サブスクリプションを削除',
content: '「{{name}}」を削除してもよろしいですか?',
contentWithApps: 'このサブスクリプションは {{count}} 個のアプリで使用されています。「{{name}}」を削除してもよろしいですか?',
confirm: '削除',
cancel: 'キャンセル',
},
},
status: {
active: 'アクティブ',
inactive: '非アクティブ',
},
usedByNum: '{{num}} ワークフローで使用中',
noUsed: 'ワークフローで使用されていません',
},
},
addType: {
title: 'サブスクリプションを追加',
description: 'トリガーサブスクリプションの作成方法を選択してください',
options: {
apiKey: {
title: 'API キー経由',
description: 'API 認証情報を使用してサブスクリプションを自動作成',
},
oauth: {
title: 'OAuth 経由',
description: 'サードパーティプラットフォームで認証してサブスクリプションを作成',
},
manual: {
title: '手動設定',
description: 'URL を貼り付けて新しいサブスクリプションを作成',
tip: 'サードパーティプラットフォームで URL を手動設定',
},
},
},
},
modal: {
steps: {
verify: '検証',
configuration: '設定',
},
common: {
cancel: 'キャンセル',
back: '戻る',
next: '次へ',
create: '作成',
verify: '検証',
authorize: '認証',
creating: '作成中...',
verifying: '検証中...',
authorizing: '認証中...',
},
oauthRedirectInfo: 'このツールプロバイダーのシステムクライアントシークレットが見つからないため、手動設定が必要です。redirect_uri には以下を使用してください',
apiKey: {
title: 'API キーで作成',
verify: {
title: '認証情報を検証',
description: 'アクセスを検証するために API 認証情報を提供してください',
error: '認証情報の検証に失敗しました。API キーをご確認ください。',
success: '認証情報が正常に検証されました',
},
configuration: {
title: 'サブスクリプションを設定',
description: 'サブスクリプションパラメータを設定',
},
},
oauth: {
title: 'OAuth で作成',
authorization: {
title: 'OAuth 認証',
description: 'Dify があなたのアカウントにアクセスすることを認証',
redirectUrl: 'リダイレクト URL',
redirectUrlHelp: 'OAuth アプリ設定でこの URL を使用',
authorizeButton: '{{provider}} で認証',
waitingAuth: '認証を待機中...',
authSuccess: '認証が成功しました',
authFailed: '認証に失敗しました',
},
configuration: {
title: 'サブスクリプションを設定',
description: '認証後にサブスクリプションパラメータを設定',
success: 'OAuth設定が成功しました',
},
},
manual: {
title: '手動設定',
description: 'Webhook サブスクリプションを手動で設定',
instruction: {
title: '設定手順',
step1: '1. 以下のコールバック URL をコピー',
step2: '2. サードパーティプラットフォームの Webhook 設定に移動',
step3: '3. コールバック URL を Webhook エンドポイントとして追加',
step4: '4. 受信したいイベントを設定',
step5: '5. イベントをトリガーして Webhook をテスト',
step6: '6. ここに戻って Webhook が動作していることを確認し、設定を完了',
},
logs: {
title: 'リクエストログ',
description: '受信 Webhook リクエストを監視',
empty: 'まだリクエストを受信していません。Webhook 設定をテストしてください。',
status: {
success: '成功',
error: 'エラー',
},
expandAll: 'すべて展開',
collapseAll: 'すべて折りたたむ',
timestamp: 'タイムスタンプ',
method: 'メソッド',
path: 'パス',
headers: 'ヘッダー',
body: 'ボディ',
response: 'レスポンス',
},
},
form: {
subscriptionName: {
label: 'サブスクリプション名',
placeholder: 'サブスクリプション名を入力',
required: 'サブスクリプション名は必須です',
},
callbackUrl: {
label: 'コールバック URL',
description: 'この URL で Webhook イベントを受信します',
copy: 'コピー',
copied: 'コピーしました!',
},
},
errors: {
createFailed: 'サブスクリプションの作成に失敗しました',
verifyFailed: '認証情報の検証に失敗しました',
authFailed: '認証に失敗しました',
networkError: 'ネットワークエラーです。再試行してください',
},
},
events: {
title: '利用可能なイベント',
description: 'このトリガープラグインが購読できるイベント',
empty: '利用可能なイベントがありません',
actionNum: '{{num}} {{event}} が含まれています',
item: {
parameters: '{{count}} パラメータ',
},
},
provider: {
github: 'GitHub',
gitlab: 'GitLab',
notion: 'Notion',
webhook: 'Webhook',
},
}
export default translation

View File

@@ -7,6 +7,11 @@ const translation = {
description: '创建您的第一个订阅以开始接收事件',
button: '新建订阅',
},
createButton: {
oauth: '通过 OAuth 新建订阅',
apiKey: '通过 API Key 新建订阅',
manual: '粘贴 URL 以创建新订阅',
},
list: {
title: '订阅列表',
addButton: '添加',
@@ -91,22 +96,24 @@ const translation = {
title: '通过OAuth创建',
authorization: {
title: 'OAuth授权',
description: '授权Dify访问您的账户',
redirectUrl: '重定向URL',
redirectUrlHelp: '在您的OAuth应用配置中使用此URL',
authorizeButton: '使用{{provider}}授权',
description: '授权 Dify 访问您的账户',
redirectUrl: '重定向 URL',
redirectUrlHelp: '在您的 OAuth 应用配置中使用此 URL',
authorizeButton: '使用 {{provider}} 授权',
waitingAuth: '等待授权中...',
authSuccess: '授权成功',
authFailed: '授权失败',
waitingJump: '已授权,待跳转',
},
configuration: {
title: '配置订阅',
description: '授权完成后设置您的订阅参数',
success: 'OAuth 配置成功',
},
},
manual: {
title: '手动设置',
description: '手动配置您的Webhook订阅',
description: '手动配置您的 Webhook 订阅',
instruction: {
title: '设置说明',
step1: '1. 复制下方的回调URL',

View File

@@ -16,6 +16,7 @@ const translation = {
agent: 'Agent 策略',
extension: '扩展',
bundle: '插件集',
trigger: '触发器',
},
search: '搜索',
allCategories: '所有类别',

View File

@@ -76,6 +76,7 @@
"classnames": "^2.5.1",
"cmdk": "^1.1.1",
"copy-to-clipboard": "^3.3.3",
"cron-parser": "^5.4.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"decimal.js": "^10.4.3",

61
web/pnpm-lock.yaml generated
View File

@@ -147,6 +147,9 @@ importers:
copy-to-clipboard:
specifier: ^3.3.3
version: 3.3.3
cron-parser:
specifier: ^5.4.0
version: 5.4.0
crypto-js:
specifier: ^4.2.0
version: 4.2.0
@@ -1721,170 +1724,144 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm64@1.2.0':
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.0':
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.0':
resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.0':
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.0':
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm64@0.34.3':
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.3':
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.3':
resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.3':
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.3':
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-arm64@0.34.3':
resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.3':
resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -2168,28 +2145,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.5.0':
resolution: {integrity: sha512-biWqIOE17OW/6S34t1X8K/3vb1+svp5ji5QQT/IKR+VfM3B7GvlCwmz5XtlEan2ukOUf9tj2vJJBffaGH4fGRw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.5.0':
resolution: {integrity: sha512-zPisT+obYypM/l6EZ0yRkK3LEuoZqHaSoYKj+5jiD9ESHwdr6QhnabnNxYkdy34uCigNlWIaCbjFmQ8FY5AlxA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.5.0':
resolution: {integrity: sha512-+t3+7GoU9IYmk+N+FHKBNFdahaReoAktdOpXHFIPOU1ixxtdge26NgQEEkJkCw2dHT9UwwK5zw4mAsURw4E8jA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.5.0':
resolution: {integrity: sha512-d8MrXKh0A+c9DLiy1BUFwtg3Hu90Lucj3k6iKTUdPOv42Ve2UiIG8HYi3UAb8kFVluXxEfdpCoPPCSODk5fDcw==}
@@ -2411,42 +2384,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -3561,49 +3528,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -4407,6 +4366,10 @@ packages:
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cron-parser@5.4.0:
resolution: {integrity: sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==}
engines: {node: '>=18'}
cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@@ -6275,6 +6238,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -12980,6 +12947,10 @@ snapshots:
create-require@1.1.1: {}
cron-parser@5.4.0:
dependencies:
luxon: 3.7.2
cross-env@7.0.3:
dependencies:
cross-spawn: 7.0.6
@@ -15361,6 +15332,8 @@ snapshots:
dependencies:
yallist: 3.1.1
luxon@3.7.2: {}
lz-string@1.5.0: {}
magic-string@0.30.17:

View File

@@ -159,11 +159,7 @@ export const updateTracingStatus: Fetcher<CommonResponse, { appId: string; body:
// Webhook Trigger
export const fetchWebhookUrl: Fetcher<WebhookTriggerResponse, { appId: string; nodeId: string }> = ({ appId, nodeId }) => {
return post<WebhookTriggerResponse>(`apps/${appId}/workflows/triggers/webhook`, { params: { node_id: nodeId } })
}
export const deleteWebhookUrl: Fetcher<CommonResponse, { appId: string; nodeId: string }> = ({ appId, nodeId }) => {
return del<CommonResponse>(`apps/${appId}/workflows/triggers/webhook`, { params: { node_id: nodeId } })
return get<WebhookTriggerResponse>(`apps/${appId}/workflows/triggers/webhook`, { params: { node_id: nodeId } })
}
export const fetchTracingConfig: Fetcher<TracingConfig & { has_not_configured: true }, { appId: string; provider: TracingProvider }> = ({ appId, provider }) => {

View File

@@ -95,6 +95,15 @@ export const useInvalidateAllTriggerPlugins = () => {
}
// ===== Trigger Subscriptions Management =====
export const useTriggerProviderInfo = (provider: string, enabled = true) => {
return useQuery<TriggerProviderApiEntity>({
queryKey: [NAME_SPACE, 'provider-info', provider],
queryFn: () => get<TriggerProviderApiEntity>(`/workspaces/current/trigger-provider/${provider}/info`),
enabled: enabled && !!provider,
})
}
export const useTriggerSubscriptions = (provider: string, enabled = true) => {
return useQuery<TriggerSubscription[]>({
queryKey: [NAME_SPACE, 'list-subscriptions', provider],
@@ -157,7 +166,7 @@ export const useVerifyTriggerSubscriptionBuilder = () => {
credentials?: Record<string, any>
}) => {
const { provider, subscriptionBuilderId, ...body } = payload
return post(
return post<{ verified: boolean }>(
`/workspaces/current/trigger-provider/${provider}/subscriptions/builder/verify/${subscriptionBuilderId}`,
{ body },
)
@@ -171,7 +180,7 @@ export const useBuildTriggerSubscription = () => {
mutationFn: (payload: {
provider: string
subscriptionBuilderId: string
params?: Record<string, any>
[key: string]: any
}) => {
const { provider, subscriptionBuilderId, ...body } = payload
return post(
@@ -267,10 +276,11 @@ export const useTriggerPluginDynamicOptions = (payload: {
provider: string
action: string
parameter: string
credential_id: string
extra?: Record<string, any>
}, enabled = true) => {
return useQuery<{ options: Array<{ value: string; label: any }> }>({
queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.extra],
queryKey: [NAME_SPACE, 'dynamic-options', payload.plugin_id, payload.provider, payload.action, payload.parameter, payload.credential_id, payload.extra],
queryFn: () => get<{ options: Array<{ value: string; label: any }> }>(
'/workspaces/current/plugin/parameters/dynamic-options',
{
@@ -280,7 +290,7 @@ export const useTriggerPluginDynamicOptions = (payload: {
},
},
),
enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter,
enabled: enabled && !!payload.plugin_id && !!payload.provider && !!payload.action && !!payload.parameter && !!payload.credential_id,
})
}