mirror of
https://github.com/langgenius/dify.git
synced 2026-02-10 02:03:59 +00:00
Compare commits
22 Commits
feat/trigg
...
feat/trigg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9a741781 | ||
|
|
c5de91ba94 | ||
|
|
bc1e6e011b | ||
|
|
906028b1fb | ||
|
|
034602969f | ||
|
|
4ca14bfdad | ||
|
|
59f56d8c94 | ||
|
|
63d26f0478 | ||
|
|
eae65e55ce | ||
|
|
0edf06329f | ||
|
|
6943a379c9 | ||
|
|
e49534b70c | ||
|
|
344616ca2f | ||
|
|
0e287a9c93 | ||
|
|
8141f53af5 | ||
|
|
5a6cb0d887 | ||
|
|
26e7677595 | ||
|
|
814b0e1fe8 | ||
|
|
a173dc5c9d | ||
|
|
a567facf2b | ||
|
|
e76d80defe | ||
|
|
4a17025467 |
@@ -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
|
||||
|
||||
@@ -247,6 +247,7 @@ class Event(BaseModel):
|
||||
|
||||
class TriggerInvokeResponse(BaseModel):
|
||||
event: Event
|
||||
cancelled: Optional[bool] = False
|
||||
|
||||
|
||||
class PluginTriggerDispatchResponse(BaseModel):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
class TriggerProviderCredentialValidationError(ValueError):
|
||||
pass
|
||||
|
||||
class TriggerInvokeError(Exception):
|
||||
pass
|
||||
|
||||
class TriggerIgnoreEventError(TriggerInvokeError):
|
||||
pass
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
381
api/tests/unit_tests/libs/test_cron_compatibility.py
Normal file
381
api/tests/unit_tests/libs/test_cron_compatibility.py
Normal 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()
|
||||
411
api/tests/unit_tests/libs/test_schedule_utils_enhanced.py
Normal file
411
api/tests/unit_tests/libs/test_schedule_utils_enhanced.py
Normal 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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum AuthCategory {
|
||||
tool = 'tool',
|
||||
datasource = 'datasource',
|
||||
model = 'model',
|
||||
trigger = 'trigger',
|
||||
}
|
||||
|
||||
export type PluginPayload = {
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
22
web/app/components/plugins/plugin-detail-panel/store.ts
Normal file
22
web/app/components/plugins/plugin-detail-panel/store.ts
Normal 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 }),
|
||||
}))
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 >
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 || []),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -16,6 +16,7 @@ const translation = {
|
||||
agent: 'Agent Strategy',
|
||||
extension: 'Extension',
|
||||
bundle: 'Bundle',
|
||||
trigger: 'Trigger',
|
||||
},
|
||||
search: 'Search',
|
||||
allCategories: 'All Categories',
|
||||
|
||||
180
web/i18n/ja-JP/plugin-trigger.ts
Normal file
180
web/i18n/ja-JP/plugin-trigger.ts
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -16,6 +16,7 @@ const translation = {
|
||||
agent: 'Agent 策略',
|
||||
extension: '扩展',
|
||||
bundle: '插件集',
|
||||
trigger: '触发器',
|
||||
},
|
||||
search: '搜索',
|
||||
allCategories: '所有类别',
|
||||
|
||||
@@ -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
61
web/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user